docs(08-mobile-pwa): create phase plan
This commit is contained in:
335
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
335
.planning/phases/08-mobile-pwa/08-03-PLAN.md
Normal file
@@ -0,0 +1,335 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user