--- phase: 26-pwa-performance plan: 04 type: execute wave: 4 depends_on: - 26-00 - 26-02 - 26-03 files_modified: - packages/db/src/schema/push_subscriptions.ts - packages/db/src/schema/index.ts - packages/db/src/migrations/0055_create_push_subscriptions.sql - server/src/services/pushService.ts - server/src/routes/push.ts - server/src/app.ts - ui/src/hooks/usePushNotifications.ts - ui/src/api/push.ts - ui/src/components/NotificationPermissionPrompt.tsx - ui/src/components/ChatPanel.tsx autonomous: true requirements: - PWA-06 must_haves: truths: - "Server exposes VAPID public key via GET /api/push/vapid-public-key" - "Client can subscribe to push notifications via POST /api/push/subscribe" - "Client can unsubscribe via DELETE /api/push/subscribe" - "Server can send push notifications to all subscriptions for a given company" - "Permission prompt appears after third agent response, not on first load" - "Push subscriptions are stored in PostgreSQL push_subscriptions table" - "Stale subscriptions (410 Gone) are auto-deleted on send failure" artifacts: - path: "packages/db/src/schema/push_subscriptions.ts" provides: "Push subscription DB table" contains: "pushSubscriptions" - path: "packages/db/src/migrations/0055_create_push_subscriptions.sql" provides: "SQL migration for push_subscriptions table" contains: "CREATE TABLE" - path: "server/src/services/pushService.ts" provides: "VAPID config + sendPush helper" contains: "sendNotification" - path: "server/src/routes/push.ts" provides: "Push API routes" contains: "vapid-public-key" - path: "ui/src/hooks/usePushNotifications.ts" provides: "Push subscription management hook" contains: "pushManager.subscribe" - path: "ui/src/components/NotificationPermissionPrompt.tsx" provides: "Permission request UI" contains: "Stay in the loop" key_links: - from: "ui/src/hooks/usePushNotifications.ts" to: "server/src/routes/push.ts" via: "POST /api/push/subscribe with subscription JSON" pattern: "/api/push/subscribe" - from: "server/src/services/pushService.ts" to: "web-push" via: "webPush.sendNotification()" pattern: "sendNotification" - from: "server/src/routes/push.ts" to: "packages/db/src/schema/push_subscriptions.ts" via: "Drizzle insert/delete on push_subscriptions table" pattern: "pushSubscriptions" --- Wire push notifications end-to-end: DB schema for subscriptions, server VAPID config + push routes, client subscription hook, and notification permission prompt UI. Purpose: Delivers PWA-06 (push notifications for agent mentions, task completions, and handoff requests). Server stores subscriptions and sends notifications. Client requests permission after user engagement. Output: 1 new DB table + migration, 1 server service, 1 server route file, 1 client API module, 1 hook, 1 component. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/26-pwa-performance/26-RESEARCH.md @.planning/phases/26-pwa-performance/26-UI-SPEC.md @.planning/phases/26-pwa-performance/26-03-SUMMARY.md @packages/db/src/schema/index.ts @server/src/app.ts @server/src/routes/chat.ts From packages/db/src/schema (Drizzle pg-core pattern — matches all existing tables): ```typescript // All tables use: pgTable(), uuid(), text(), timestamp(), index() from "drizzle-orm/pg-core" // UUID ids via uuid("id").primaryKey().defaultRandom() // Timestamps via timestamp("created_at", { withTimezone: true }).notNull().defaultNow() // Index callbacks use object-syntax: (table) => ({}) ``` From server/src/app.ts (route mounting): ```typescript // Routes mounted via: app.use("/api/push", pushRoutes(db)); // Pattern matches: chatRoutes(db), fileRoutes(db), etc. ``` From ui/public/sw.js (created in Plan 00): ```javascript // SW handles 'push' event and shows notification // SW handles 'notificationclick' event and opens URL ``` Task 1: Create push_subscriptions DB schema, migration, pushService, and push routes packages/db/src/schema/push_subscriptions.ts, packages/db/src/schema/index.ts, packages/db/src/migrations/0055_create_push_subscriptions.sql, server/src/services/pushService.ts, server/src/routes/push.ts, server/src/app.ts - packages/db/src/schema/chat_files.ts - packages/db/src/schema/index.ts - packages/db/src/migrations/0053_create_chat_files.sql - server/src/routes/chat.ts - server/src/app.ts - .planning/phases/26-pwa-performance/26-RESEARCH.md 1. Create `packages/db/src/schema/push_subscriptions.ts`: - Import from `drizzle-orm/pg-core`: `pgTable`, `uuid`, `text`, `timestamp`, `index` - Define `pushSubscriptions` table with columns: a. `id` — `uuid("id").primaryKey().defaultRandom()` b. `endpoint` — `text("endpoint").notNull()` (push service endpoint URL) c. `p256dh` — `text("p256dh").notNull()` (client public key) d. `auth` — `text("auth").notNull()` (client auth secret) e. `userId` — `uuid("user_id")` (nullable — local mode may not have auth) f. `companyId` — `uuid("company_id")` (nullable — for scoping notifications) g. `deviceLabel` — `text("device_label")` (optional user-agent or device name) h. `createdAt` — `timestamp("created_at", { withTimezone: true }).notNull().defaultNow()` - Index: `(table) => ({ endpointIdx: index("push_sub_endpoint_idx").on(table.endpoint) })` - Use the `(table) => ({})` object-syntax for index callbacks (matches codebase convention) 2. Create `packages/db/src/migrations/0055_create_push_subscriptions.sql`: - Follow the same pattern as `0053_create_chat_files.sql` - SQL: ```sql CREATE TABLE IF NOT EXISTS "push_subscriptions" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, "endpoint" text NOT NULL, "p256dh" text NOT NULL, "auth" text NOT NULL, "user_id" uuid, "company_id" uuid, "device_label" text, "created_at" timestamp with time zone DEFAULT now() NOT NULL ); CREATE INDEX IF NOT EXISTS "push_sub_endpoint_idx" ON "push_subscriptions" ("endpoint"); ``` 3. Update `packages/db/src/schema/index.ts`: - Add `export * from "./push_subscriptions";` 4. Create `server/src/services/pushService.ts`: - Import `webPush` from `"web-push"` - Import `eq` from `"drizzle-orm"` - Import `pushSubscriptions` from the schema - Type `db` parameter as the Drizzle database type used across the codebase (check chat.ts for the pattern) - `initVapid()` function: call `webPush.setVapidDetails(process.env.VAPID_SUBJECT || "mailto:admin@nexus.local", process.env.VAPID_PUBLIC_KEY!, process.env.VAPID_PRIVATE_KEY!)` — call at service creation time, only if VAPID_PUBLIC_KEY is set (graceful skip if not configured) - `getVapidPublicKey()`: returns `process.env.VAPID_PUBLIC_KEY || null` - `saveSubscription(db, { endpoint, p256dh, auth, userId?, companyId?, deviceLabel? })`: generate UUID via `crypto.randomUUID()`, insert into `pushSubscriptions`, return the id - `removeSubscription(db, endpoint)`: delete from `pushSubscriptions` where `endpoint` matches - `sendPushToAll(db, companyId, payload: { title: string; body: string; icon?: string; data?: Record })`: a. Query all subscriptions (filter by companyId if provided, otherwise all) b. For each subscription, call `webPush.sendNotification({ endpoint, keys: { p256dh, auth } }, JSON.stringify(payload))` c. On `410 Gone` or `404 Not Found` response, delete the stale subscription from DB (per RESEARCH Pitfall 6) d. Log errors but don't throw — push is best-effort - Export the functions (not a class — matches codebase service pattern in chat.ts) 5. Create `server/src/routes/push.ts`: - Export `function pushRoutes(db: ...)` returning an Express Router (match pattern in chat.ts) - `GET /vapid-public-key` — returns `{ publicKey: getVapidPublicKey() }` or 404 if not configured - `POST /subscribe` — body: `{ endpoint, keys: { p256dh, auth }, userId?, companyId?, deviceLabel? }` — calls `saveSubscription`, returns 201 - `DELETE /subscribe` — body: `{ endpoint }` — calls `removeSubscription`, returns 204 6. Update `server/src/app.ts`: - Import `pushRoutes` from `"./routes/push"` - Import `initVapid` from `"./services/pushService"` - Mount: `app.use("/api/push", pushRoutes(db));` - Call `initVapid()` at app startup (after db init, before listen) — wrap in try/catch, log warning if VAPID keys not configured grep -q "pgTable" packages/db/src/schema/push_subscriptions.ts && grep -q "push_subscriptions" packages/db/src/schema/index.ts && grep -q "CREATE TABLE" packages/db/src/migrations/0055_create_push_subscriptions.sql && grep -q "sendNotification" server/src/services/pushService.ts && grep -q "vapid-public-key" server/src/routes/push.ts && grep -q "pushRoutes" server/src/app.ts && echo "PASS" - `push_subscriptions.ts` uses `pgTable` from `drizzle-orm/pg-core` (NOT sqlite-core) - `push_subscriptions.ts` uses `uuid("id").primaryKey().defaultRandom()` for id column - `push_subscriptions.ts` uses `timestamp("created_at", { withTimezone: true }).notNull().defaultNow()` for timestamp - `push_subscriptions.ts` uses object-syntax `(table) => ({})` for index callback - `0055_create_push_subscriptions.sql` migration exists with CREATE TABLE and CREATE INDEX - `schema/index.ts` exports push_subscriptions - `pushService.ts` initializes VAPID only if env vars are set (graceful skip) - `pushService.ts` deletes stale subscriptions on 410/404 response - `push.ts` routes: GET `/vapid-public-key`, POST `/subscribe`, DELETE `/subscribe` - `app.ts` mounts push routes and calls `initVapid()` - `pnpm --filter @paperclipai/server build` succeeds (if build script exists) or TypeScript compiles Push notification backend complete: DB table with pg-core schema, SQL migration, VAPID service, API routes, app mounting. Stale subscriptions auto-cleaned. Task 2: Create push API client, usePushNotifications hook, and NotificationPermissionPrompt ui/src/api/push.ts, ui/src/hooks/usePushNotifications.ts, ui/src/components/NotificationPermissionPrompt.tsx, ui/src/components/ChatPanel.tsx - ui/src/api/chat.ts - ui/src/hooks/useInstallPrompt.ts - ui/src/components/ChatPanel.tsx - .planning/phases/26-pwa-performance/26-UI-SPEC.md - .planning/phases/26-pwa-performance/26-RESEARCH.md 1. Create `ui/src/api/push.ts`: - Export `pushApi` object with: a. `getVapidPublicKey(): Promise<{ publicKey: string | null }>` — GET `/api/push/vapid-public-key` b. `subscribe(subscription: PushSubscriptionJSON, meta?: { userId?: string; companyId?: string; deviceLabel?: string }): Promise` — POST `/api/push/subscribe` with `{ ...subscription.toJSON(), ...meta }` c. `unsubscribe(endpoint: string): Promise` — DELETE `/api/push/subscribe` with `{ endpoint }` - Follow the same fetch pattern used in `chat.ts` (read it for the pattern — likely uses fetch with JSON headers) 2. Create `ui/src/hooks/usePushNotifications.ts`: - Export `function usePushNotifications(): { isSupported: boolean; permission: NotificationPermission | "unsupported"; subscribe: () => Promise; unsubscribe: () => Promise }` - `isSupported`: `"serviceWorker" in navigator && "PushManager" in window && "Notification" in window` - `permission`: `useState(Notification.permission ?? "default")` — poll via `useEffect` if permission might change - `subscribe` callback: a. Request permission: `const perm = await Notification.requestPermission()` — update state b. If `perm !== "granted"`, return early c. Get VAPID public key: `const { publicKey } = await pushApi.getVapidPublicKey()` — if null, return early d. Get SW registration: `const reg = await navigator.serviceWorker.ready` e. Subscribe: `const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) })` f. Send to server: `await pushApi.subscribe(sub.toJSON())` - `unsubscribe` callback: a. Get SW registration and existing subscription b. Call `sub.unsubscribe()` c. Call `pushApi.unsubscribe(sub.endpoint)` - Include `urlBase64ToUint8Array` utility function (per RESEARCH code example — converts base64 VAPID key to Uint8Array) 3. Create `ui/src/components/NotificationPermissionPrompt.tsx`: - Import `usePushNotifications` hook - Internal state: `dismissed` (from `localStorage.getItem("nexus.notifPromptDismissed")`) - Props: `{ agentResponseCount: number }` (engagement gate — show after 3rd agent response) - Show conditions (ALL must be true): a. `isSupported === true` b. `permission === "default"` (not already granted or denied) c. `!dismissed` (not previously dismissed — stored in `nexus.notifPromptDismissed` localStorage key) d. `agentResponseCount >= 3` (engagement gate per UI-SPEC) - Layout: a. Container: `fixed bottom-20 left-4 right-4 md:bottom-auto md:top-16 md:left-auto md:right-4 md:max-w-sm z-50 bg-card border border-border rounded-lg shadow-lg p-4` b. Heading: "Stay in the loop" in `text-sm font-semibold` c. Body: "Get notified when your agents complete tasks or need input." in `text-xs text-muted-foreground mt-1` d. Buttons row: `flex gap-2 mt-3` - "Allow notifications" — `