--- phase: 08-mobile-pwa plan: 03 subsystem: ui tags: [push-notifications, pwa, service-worker, indexeddb, web-push, vapid, offline-queue] # Dependency graph requires: - phase: 08-01 provides: Serwist service worker setup, PWA manifest, app/sw.ts baseline - phase: 08-02 provides: Mobile nav, MobileMoreSheet, use-chat-socket.ts WebSocket hook provides: - Push notification subscription storage (push_subscriptions DB table, Alembic migration 012) - Push notification API (subscribe, unsubscribe, send endpoints in shared/api/push.py) - Server-side VAPID push delivery via pywebpush - Service worker push + notificationclick handlers with conversation deep-link - PushPermission opt-in component (default/granted/denied/unsupported states) - InstallPrompt second-visit PWA install banner (Android + iOS) - IndexedDB offline message queue (enqueueMessage + drainQueue) - Offline-aware use-chat-socket (enqueues when disconnected, drains on reconnect) affects: [portal, gateway, shared, migrations] # Tech tracking tech-stack: added: - pywebpush (gateway dependency, server-side VAPID push delivery) - idb (already installed in portal, used for IndexedDB offline queue) patterns: - Push notification gate on connected user tracking via in-memory _connected_users dict - VAPID key pair in env (NEXT_PUBLIC_VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY) - Offline queue: enqueue in IndexedDB when WS disconnected, drain on ws.onopen - Service worker events extend Serwist base with addEventListener (not installSerwist) - urlBase64ToArrayBuffer (not Uint8Array) for VAPID applicationServerKey — TypeScript strict mode requires ArrayBuffer not Uint8Array key-files: created: - packages/shared/shared/models/push.py - packages/shared/shared/api/push.py - migrations/versions/012_push_subscriptions.py - packages/portal/components/push-permission.tsx - packages/portal/components/install-prompt.tsx - packages/portal/lib/message-queue.ts modified: - packages/portal/app/sw.ts - packages/portal/lib/use-chat-socket.ts - packages/portal/app/(dashboard)/layout.tsx - packages/portal/components/mobile-more-sheet.tsx - packages/shared/shared/api/__init__.py - packages/gateway/gateway/main.py - packages/gateway/gateway/channels/web.py - packages/gateway/pyproject.toml - .env / .env.example key-decisions: - "Migration is 012 not 010 — migrations 010/011 were used by template data migrations after plan was written" - "push router lives in shared/api/push.py (not gateway/routers/push.py) — consistent with all other API routers following shared pattern" - "Push trigger in WebSocket handler: fires asyncio.create_task() when ws_disconnected_during_stream is True — best-effort, non-blocking" - "urlBase64ToArrayBuffer returns ArrayBuffer not Uint8Array — TypeScript strict mode requires this for applicationServerKey" - "vibrate cast via spread + Record in sw.ts — lib.webworker types omit vibrate from NotificationOptions despite browser support" - "InstallPrompt: fixed bottom-20 (above tab bar) — matches position of mobile chat input, only shown on md:hidden" - "PushPermission embedded in MobileMoreSheet — non-intrusive placement, available when user explicitly opens More panel" - "Connected user tracking via module-level _connected_users dict — avoids Redis overhead for in-process WS state" patterns-established: - "Push endpoints follow shared/api/* pattern — mount in gateway main.py via push_router import" - "Offline queue uses idb openDB with schema upgrade callback — consistent IndexedDB init pattern" - "asyncio.create_task() for fire-and-forget push from WebSocket handler — never blocks response path" requirements-completed: - MOB-05 # Metrics duration: 8min completed: 2026-03-26 --- # Phase 08 Plan 03: Push Notifications, Offline Queue, Install Prompt Summary **Web Push notification pipeline (VAPID subscription -> DB storage -> pywebpush delivery -> service worker display), IndexedDB offline message queue with auto-drain on reconnect, and second-visit PWA install banner for Android and iOS.** ## Performance - **Duration:** 8 min - **Started:** 2026-03-26T03:22:15Z - **Completed:** 2026-03-26T03:30:47Z - **Tasks:** 2 - **Files modified:** 15 ## Accomplishments - Complete push notification pipeline: browser subscribes with VAPID key, subscription stored in PostgreSQL, gateway delivers via pywebpush when user's WebSocket disconnects mid-stream - IndexedDB offline message queue: messages sent while disconnected are stored and auto-drained on WebSocket reconnection (or when network comes back online) - Second-visit PWA install banner handles both Android (beforeinstallprompt API) and iOS (manual Share instructions), dismissable with localStorage persistence - Push permission opt-in embedded in MobileMoreSheet — non-intrusive but discoverable ## Task Commits Each task was committed atomically: 1. **Task 1: Push notification backend** - `7d3a393` (feat) 2. **Task 2: Push subscription client, service worker handlers, install prompt, offline queue** - `81a2ce1` (feat) **Plan metadata:** (created in next commit) ## Files Created/Modified **Created:** - `packages/shared/shared/models/push.py` - PushSubscription ORM model + Pydantic schemas - `packages/shared/shared/api/push.py` - Subscribe/unsubscribe/send API endpoints - `migrations/versions/012_push_subscriptions.py` - push_subscriptions table migration - `packages/portal/components/push-permission.tsx` - Opt-in button with permission state machine - `packages/portal/components/install-prompt.tsx` - Second-visit install banner (Android + iOS) - `packages/portal/lib/message-queue.ts` - IndexedDB offline queue (enqueue + drain) **Modified:** - `packages/portal/app/sw.ts` - Added push + notificationclick event handlers - `packages/portal/lib/use-chat-socket.ts` - Offline queue integration (enqueue/drain + online status reconnect) - `packages/portal/app/(dashboard)/layout.tsx` - Mount InstallPrompt - `packages/portal/components/mobile-more-sheet.tsx` - Mount PushPermission - `packages/shared/shared/api/__init__.py` - Export push_router - `packages/gateway/gateway/main.py` - Mount push_router - `packages/gateway/gateway/channels/web.py` - Connected user tracking + push trigger on disconnect - `packages/gateway/pyproject.toml` - Add pywebpush dependency - `.env` / `.env.example` - VAPID key env vars ## Decisions Made - Migration numbered 012 (not 010 as planned) — migrations 010 and 011 were already used by template-related data migrations created after the plan was written. - Push router placed in `shared/api/push.py` following all other API routers in the project; plan suggested `gateway/routers/push.py` but the shared pattern was already established. - Push trigger fires via `asyncio.create_task()` when the WebSocket send raises during streaming — fire-and-forget, never blocks the response path. - `applicationServerKey` uses `ArrayBuffer` not `Uint8Array` — TypeScript strict mode requires this distinction for `PushManager.subscribe()`. - `vibrate` option cast via spread to `Record` — TypeScript's `lib.webworker` omits `vibrate` from `NotificationOptions` even though all major browsers support it. ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Migration number adjusted from 010 to 012** - **Found during:** Task 1 (migration creation) - **Issue:** Migrations 010 and 011 were already used by template data migrations created after the plan was written - **Fix:** Created migration as 012 with down_revision 011 - **Files modified:** migrations/versions/012_push_subscriptions.py - **Verification:** Migration file compiles, correct revision chain - **Committed in:** 7d3a393 **2. [Rule 1 - Bug] urlBase64ToArrayBuffer returns ArrayBuffer (not Uint8Array)** - **Found during:** Task 2 (build verification) - **Issue:** TypeScript strict types reject Uint8Array for applicationServerKey — requires ArrayBuffer - **Fix:** Changed return type and implementation to use ArrayBuffer with Uint8Array view - **Files modified:** packages/portal/components/push-permission.tsx - **Verification:** npm run build passes - **Committed in:** 81a2ce1 **3. [Rule 1 - Bug] vibrate option cast in service worker** - **Found during:** Task 2 (build verification) - **Issue:** TypeScript lib.webworker types don't include vibrate in NotificationOptions despite browser support - **Fix:** Cast notification options to include vibrate via Record spread - **Files modified:** packages/portal/app/sw.ts - **Verification:** npm run build passes - **Committed in:** 81a2ce1 --- **Total deviations:** 3 auto-fixed (1 migration numbering, 2 TypeScript strict type issues from build verification) **Impact on plan:** All auto-fixes necessary for correctness and build success. No scope creep. ## Issues Encountered None beyond the auto-fixed TypeScript strict type issues above. ## User Setup Required VAPID keys have been pre-generated and added to `.env`. For production deployments, generate new keys: ```bash cd packages/portal && npx web-push generate-vapid-keys ``` Then set `NEXT_PUBLIC_VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, and `VAPID_CLAIMS_EMAIL` in your environment. ## Next Phase Readiness Phase 08 (Mobile PWA) is now complete — all 3 plans delivered: - 08-01: Service worker, offline caching, PWA manifest, web app manifest - 08-02: Mobile navigation, chat UI improvements, responsive layout - 08-03: Push notifications, offline queue, install prompt The portal is now a fully-featured PWA with push notifications, offline support, and installability. --- *Phase: 08-mobile-pwa* *Completed: 2026-03-26*