Files

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-mobile-pwa 03 execute 2
08-01
08-02
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
true
MOB-05
truths artifacts key_links
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
path provides exports
packages/portal/components/install-prompt.tsx Smart install banner for PWA (second visit)
InstallPrompt
path provides exports
packages/portal/components/push-permission.tsx Push notification opt-in UI
PushPermission
path provides exports
packages/portal/lib/message-queue.ts IndexedDB offline message queue
enqueueMessage
drainQueue
path provides
packages/portal/app/actions/push.ts Server actions for push subscription management and sending
path provides
packages/gateway/routers/push.py Push subscription CRUD API endpoints
path provides
migrations/versions/010_push_subscriptions.py push_subscriptions table migration
from to via pattern
packages/portal/app/sw.ts push event handler self.addEventListener('push', ...) shows notification addEventListener.*push
from to via pattern
packages/portal/app/sw.ts notificationclick handler self.addEventListener('notificationclick', ...) opens conversation notificationclick
from to via pattern
packages/gateway/routers/push.py packages/shared/models/push.py stores PushSubscription in DB push_subscriptions
from to via pattern
packages/portal/lib/use-chat-socket.ts packages/portal/lib/message-queue.ts enqueue when offline, drain on reconnect enqueueMessage|drainQueue
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.

<execution_context> @/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md @/home/adelorenzo/.claude/get-shit-done/templates/summary.md </execution_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

From packages/portal/app/sw.ts (created in Plan 01):

// 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):

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:

# 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/:

# Pydantic models and SQLAlchemy ORM models
# New PushSubscription model goes here

From migrations/versions/:

# Alembic migrations — latest is 009_*
# Next migration: 010_push_subscriptions
Task 1: Push notification backend — DB migration, API endpoints, VAPID setup 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 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
cd /home/adelorenzo/repos/konstruct && python -m py_compile packages/gateway/routers/push.py && python -m py_compile packages/shared/models/push.py 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. Task 2: Push subscription client, service worker push handler, install prompt, offline queue 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 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.
cd /home/adelorenzo/repos/konstruct/packages/portal && npm run build 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. - `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)

<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>

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