---
phase: 08-mobile-pwa
plan: 03
type: execute
wave: 2
depends_on:
  - "08-01"
  - "08-02"
files_modified:
  - packages/portal/app/sw.ts
  - packages/portal/components/install-prompt.tsx
  - packages/portal/components/push-permission.tsx
  - packages/portal/lib/message-queue.ts
  - packages/portal/lib/use-chat-socket.ts
  - packages/portal/app/(dashboard)/layout.tsx
  - packages/portal/app/actions/push.ts
  - packages/gateway/routers/push.py
  - packages/gateway/main.py
  - packages/shared/models/push.py
  - migrations/versions/010_push_subscriptions.py
  - packages/portal/messages/en.json
  - packages/portal/messages/es.json
  - packages/portal/messages/pt.json
autonomous: true
requirements:
  - MOB-05

must_haves:
  truths:
    - "User can grant push notification permission from the portal"
    - "Push subscription is stored in the database (not just memory)"
    - "When an AI Employee responds and the user's WebSocket is not connected, a push notification is sent"
    - "Tapping a push notification opens the relevant conversation in the portal"
    - "PWA install prompt appears on second visit (not first) and is dismissable"
    - "Offline message queue stores unsent messages in IndexedDB and drains on reconnection"
    - "Stale push subscriptions (410 Gone) are deleted from the database"
  artifacts:
    - path: "packages/portal/components/install-prompt.tsx"
      provides: "Smart install banner for PWA (second visit)"
      exports: ["InstallPrompt"]
    - path: "packages/portal/components/push-permission.tsx"
      provides: "Push notification opt-in UI"
      exports: ["PushPermission"]
    - path: "packages/portal/lib/message-queue.ts"
      provides: "IndexedDB offline message queue"
      exports: ["enqueueMessage", "drainQueue"]
    - path: "packages/portal/app/actions/push.ts"
      provides: "Server actions for push subscription management and sending"
    - path: "packages/gateway/routers/push.py"
      provides: "Push subscription CRUD API endpoints"
    - path: "migrations/versions/010_push_subscriptions.py"
      provides: "push_subscriptions table migration"
  key_links:
    - from: "packages/portal/app/sw.ts"
      to: "push event handler"
      via: "self.addEventListener('push', ...) shows notification"
      pattern: "addEventListener.*push"
    - from: "packages/portal/app/sw.ts"
      to: "notificationclick handler"
      via: "self.addEventListener('notificationclick', ...) opens conversation"
      pattern: "notificationclick"
    - from: "packages/gateway/routers/push.py"
      to: "packages/shared/models/push.py"
      via: "stores PushSubscription in DB"
      pattern: "push_subscriptions"
    - from: "packages/portal/lib/use-chat-socket.ts"
      to: "packages/portal/lib/message-queue.ts"
      via: "enqueue when offline, drain on reconnect"
      pattern: "enqueueMessage|drainQueue"
---

<objective>
Push notifications, offline message queue, and PWA install prompt. Users receive notifications when AI Employees respond, messages queue offline and auto-send on reconnection, and the install banner appears on the second visit.

Purpose: Push notifications make the platform feel alive on mobile — users know immediately when their AI Employee responds. Offline message queue prevents lost messages. The install prompt drives PWA adoption.

Output: Working push notification pipeline (client subscription -> DB storage -> server-side send -> service worker display), IndexedDB message queue with auto-drain, second-visit install banner.
</objective>

<execution_context>
@/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md
@/home/adelorenzo/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-mobile-pwa/08-CONTEXT.md
@.planning/phases/08-mobile-pwa/08-RESEARCH.md
@.planning/phases/08-mobile-pwa/08-01-SUMMARY.md
@.planning/phases/08-mobile-pwa/08-02-SUMMARY.md

<interfaces>
<!-- Key types and contracts from prior plans and existing code -->

From packages/portal/app/sw.ts (created in Plan 01):
```typescript
// Serwist service worker with installSerwist()
// Push event listener to be added here
```

From packages/portal/lib/use-chat-socket.ts (modified in Plan 02 for mobile chat):
```typescript
export interface UseChatSocketOptions {
  conversationId: string;
  authHeaders: ChatSocketAuthHeaders;
  onMessage: (text: string) => void;
  onTyping: (typing: boolean) => void;
  onChunk: (token: string) => void;
  onDone: (fullText: string) => void;
}
export function useChatSocket(options: UseChatSocketOptions): { send: (text: string) => void; isConnected: boolean }
// WebSocket connects to gateway at WS_URL/ws/chat
// Uses refs for callbacks to avoid reconnection on handler changes
// NOTE: Plan 02 may have modified this file for mobile chat — read current state before editing
```

From packages/gateway/main.py:
```python
# FastAPI app with routers mounted for portal, billing, channels, llm_keys, usage, webhook
# New push router needs to be mounted here
```

From packages/shared/models/:
```python
# Pydantic models and SQLAlchemy ORM models
# New PushSubscription model goes here
```

From migrations/versions/:
```python
# Alembic migrations — latest is 009_*
# Next migration: 010_push_subscriptions
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Push notification backend — DB migration, API endpoints, VAPID setup</name>
  <files>
    packages/shared/models/push.py,
    migrations/versions/010_push_subscriptions.py,
    packages/gateway/routers/push.py,
    packages/gateway/main.py,
    packages/portal/messages/en.json,
    packages/portal/messages/es.json,
    packages/portal/messages/pt.json
  </files>
  <action>
    1. Generate VAPID keys:
       ```bash
       cd packages/portal && npx web-push generate-vapid-keys
       ```
       Add to `.env.example` and `.env`:
       - `NEXT_PUBLIC_VAPID_PUBLIC_KEY=...`
       - `VAPID_PRIVATE_KEY=...`

    2. Create `packages/shared/models/push.py`:
       - SQLAlchemy ORM model `PushSubscription`:
         - id: UUID primary key (server_default=gen_random_uuid())
         - user_id: UUID, NOT NULL, FK to portal_users.id
         - tenant_id: UUID, nullable (for scoping notifications)
         - endpoint: TEXT, NOT NULL (push service URL)
         - p256dh: TEXT, NOT NULL (encryption key)
         - auth: TEXT, NOT NULL (auth secret)
         - created_at: TIMESTAMP WITH TIME ZONE, server_default=now()
         - updated_at: TIMESTAMP WITH TIME ZONE, server_default=now(), onupdate=now()
       - Unique constraint on (user_id, endpoint) — one subscription per browser per user
       - RLS policy: users can only read/write their own subscriptions
       - Pydantic schema: PushSubscriptionCreate(endpoint, p256dh, auth), PushSubscriptionOut(id, endpoint, created_at)

    3. Create `migrations/versions/010_push_subscriptions.py`:
       - Alembic migration creating the push_subscriptions table
       - Add RLS: ENABLE ROW LEVEL SECURITY, FORCE ROW LEVEL SECURITY
       - RLS policy: user_id = current_setting('app.current_user')::uuid (or use the same tenant-based RLS pattern as other tables)
       - Actually — push subscriptions are per-user not per-tenant, so the API should filter by user_id in the query, not rely on tenant RLS. Add a simple policy or skip RLS for this table and filter in the application layer (since it's portal-user-scoped, not tenant-scoped).

    4. Create `packages/gateway/routers/push.py`:
       - POST /portal/push/subscribe — stores push subscription for authenticated user
         - Body: { endpoint, keys: { p256dh, auth } }
         - Upserts by (user_id, endpoint)
         - Returns 201
       - DELETE /portal/push/unsubscribe — removes subscription by endpoint
         - Body: { endpoint }
         - Returns 204
       - POST /portal/push/send — internal endpoint (called by orchestrator/gateway when agent responds)
         - Body: { user_id, title, body, conversation_id }
         - Looks up all push subscriptions for user_id
         - Sends via web-push library (Python equivalent: pywebpush)
         - Handles 410 Gone by deleting stale subscriptions
         - Returns 200
       - For the push send: install `pywebpush` in gateway's dependencies. Actually, since the push send needs to happen from the backend (Python), use `pywebpush` not the Node `web-push`. Add `pywebpush` to gateway's pyproject.toml.
       - Alternatively: the push send can happen from the portal (Node.js) via a Server Action. The gateway can call the portal's API or the portal can subscribe to the same Redis pub-sub channel.
       - SIMPLEST APPROACH per research: The gateway WebSocket handler already checks if a client is connected. When the orchestrator task publishes the response to Redis, the gateway WS handler receives it. If no active WebSocket session exists for that user+conversation, trigger a push notification. The push send itself should use pywebpush from the gateway since that's where the event originates.

    5. Mount push router in `packages/gateway/main.py`:
       ```python
       from routers.push import router as push_router
       app.include_router(push_router)
       ```

    6. Add `pywebpush` to gateway's pyproject.toml dependencies.

    7. Wire push notification trigger into the gateway's WebSocket response handler:
       - In the existing WebSocket handler (where it publishes agent responses to the client), add logic:
       - After receiving agent response from Redis pub-sub, check if the WebSocket for that user is still connected
       - If NOT connected, call the push send logic (or fire a Celery task) with the user_id, conversation_id, agent response preview
       - Use VAPID_PRIVATE_KEY and NEXT_PUBLIC_VAPID_PUBLIC_KEY from environment

    8. Add i18n keys for push notifications:
       - push.enableNotifications, push.enableNotificationsDescription, push.enabled, push.denied, push.notSupported
       - install.title, install.description, install.installButton, install.dismissButton, install.iosInstructions
  </action>
  <verify>
    <automated>cd /home/adelorenzo/repos/konstruct && python -m py_compile packages/gateway/routers/push.py && python -m py_compile packages/shared/models/push.py</automated>
  </verify>
  <done>
    Push subscriptions table exists in migration. Gateway has push API endpoints (subscribe, unsubscribe, send). pywebpush integrated for server-side notification delivery. Push trigger wired into WebSocket response handler — sends notification when user is not connected. VAPID keys in env.
  </done>
</task>

<task type="auto">
  <name>Task 2: Push subscription client, service worker push handler, install prompt, offline queue</name>
  <files>
    packages/portal/app/sw.ts,
    packages/portal/components/install-prompt.tsx,
    packages/portal/components/push-permission.tsx,
    packages/portal/lib/message-queue.ts,
    packages/portal/lib/use-chat-socket.ts,
    packages/portal/app/(dashboard)/layout.tsx
  </files>
  <action>
    IMPORTANT: Plan 08-02 modifies use-chat-socket.ts for mobile chat. Read the current file state before making changes — do not overwrite 08-02's modifications.

    1. Update `app/sw.ts` — add push event handlers:
       ```typescript
       // After installSerwist(...)
       self.addEventListener('push', (event) => {
         const data = event.data?.json()
         event.waitUntil(
           self.registration.showNotification(data.title, {
             body: data.body,
             icon: '/icon-192.png',
             badge: '/badge-72.png',
             data: data.data, // { conversationId }
             vibrate: [100, 50, 100],
           })
         )
       })

       self.addEventListener('notificationclick', (event) => {
         event.notification.close()
         const conversationId = event.notification.data?.conversationId
         const url = conversationId ? `/chat?id=${conversationId}` : '/chat'
         event.waitUntil(
           clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windowClients) => {
             // Focus existing window if open
             for (const client of windowClients) {
               if (client.url.includes('/chat') && 'focus' in client) {
                 client.navigate(url)
                 return client.focus()
               }
             }
             // Open new window
             return clients.openWindow(url)
           })
         )
       })
       ```

    2. Create `components/push-permission.tsx`:
       - "use client" component
       - Shows a card/banner prompting the user to enable push notifications
       - On click: requests Notification.permission, subscribes via PushManager with VAPID public key, POSTs subscription to /portal/push/subscribe
       - States: 'default' (show prompt), 'granted' (show "enabled" badge), 'denied' (show "blocked" message with instructions)
       - Handles browsers that don't support push: show "not supported" message
       - Uses `process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY` for applicationServerKey
       - Utility: `urlBase64ToUint8Array(base64String)` helper for converting VAPID key
       - Place this component in the More sheet or in a settings section — not intrusive

    3. Create `components/install-prompt.tsx`:
       - "use client" component
       - Captures `beforeinstallprompt` event on mount, stores in ref
       - Tracks visit count via localStorage ('konstruct_visit_count')
       - Shows install banner only when: visit count >= 2 AND not already in standalone mode AND prompt is available (Android/Chrome) OR is iOS
       - For iOS: detect via userAgent, show instructions "Tap Share icon, then Add to Home Screen"
       - Dismiss button: sets localStorage 'konstruct_install_dismissed' = 'true'
       - Check `window.matchMedia('(display-mode: standalone)').matches` — if already installed, never show
       - Style: fixed bottom-20 (above tab bar) left/right margin, rounded card with app icon, text, install button, dismiss X
       - On install click: call deferredPrompt.prompt(), await userChoice

    4. Create `lib/message-queue.ts` — IndexedDB offline queue using `idb`:
       ```typescript
       import { openDB } from 'idb'
       const DB_NAME = 'konstruct-offline'
       const STORE = 'message-queue'
       export async function enqueueMessage(conversationId: string, text: string) { ... }
       export async function drainQueue(send: (convId: string, text: string) => void) { ... }
       ```

    5. Update `lib/use-chat-socket.ts` — integrate offline queue:
       - Read the file first to see 08-02's changes, then add offline queue integration on top
       - Import enqueueMessage and drainQueue from message-queue
       - In the `send` function: if WebSocket is not connected (isConnected is false), call `enqueueMessage(conversationId, text)` instead of sending via WebSocket
       - On reconnection (when WebSocket opens): call `drainQueue((convId, text) => ws.send(...))` to send queued messages
       - Add `useOnlineStatus` check — when transitioning from offline to online, trigger reconnection

    6. Mount `<InstallPrompt />` and `<PushPermission />` in `app/(dashboard)/layout.tsx`:
       - InstallPrompt: rendered at the bottom of the layout (above MobileNav)
       - PushPermission: rendered inside the More sheet or as a one-time prompt after first login
       - Actually, a simpler approach: add a "Notifications" toggle in the More sheet that triggers push permission. The PushPermission component can be a button within the MobileMoreSheet.
  </action>
  <verify>
    <automated>cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build</automated>
  </verify>
  <done>
    Service worker handles push events and shows notifications with conversation deep-link. Push permission UI available in the portal. Install prompt appears on second visit and is dismissable. Offline message queue stores messages in IndexedDB and auto-drains on reconnection. Build passes.
  </done>
</task>

</tasks>

<verification>
- `npm run build` passes in packages/portal
- Python files compile without errors
- Service worker source (app/sw.ts) includes push and notificationclick handlers
- Push subscription API endpoints registered on gateway
- Migration 010 creates push_subscriptions table
- Install prompt component handles both Android (beforeinstallprompt) and iOS (manual instructions)
</verification>

<success_criteria>
Push notifications are delivered when the user's PWA is not in the foreground. Tapping a notification opens the conversation. Offline messages queue in IndexedDB and send on reconnection. Install prompt shows on second visit, not first.
</success_criteria>

<output>
After completion, create `.planning/phases/08-mobile-pwa/08-03-SUMMARY.md`
</output>
