Files
konstruct/.planning/phases/08-mobile-pwa/08-03-PLAN.md

336 lines
16 KiB
Markdown

---
phase: 08-mobile-pwa
plan: 03
type: execute
wave: 2
depends_on:
- "08-01"
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
<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:
```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
```
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>
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:
- 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>