342 lines
16 KiB
Markdown
342 lines
16 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<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
|
|
@.planning/phases/08-mobile-pwa/08-02-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 (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
|
|
```
|
|
</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>
|
|
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.
|
|
</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>
|