--- 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" --- 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. @/home/adelorenzo/.claude/get-shit-done/workflows/execute-plan.md @/home/adelorenzo/.claude/get-shit-done/templates/summary.md @.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): ```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 ``` 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 `` and `` 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) 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. After completion, create `.planning/phases/08-mobile-pwa/08-03-SUMMARY.md`