nexus/.planning/phases/26-pwa-performance/26-04-PLAN.md

17 KiB

phase plan type wave depends_on files_modified autonomous requirements must_haves
26-pwa-performance 04 execute 4
26-00
26-02
26-03
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
true
PWA-06
truths artifacts key_links
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
path provides contains
packages/db/src/schema/push_subscriptions.ts Push subscription DB table pushSubscriptions
path provides contains
packages/db/src/migrations/0055_create_push_subscriptions.sql SQL migration for push_subscriptions table CREATE TABLE
path provides contains
server/src/services/pushService.ts VAPID config + sendPush helper sendNotification
path provides contains
server/src/routes/push.ts Push API routes vapid-public-key
path provides contains
ui/src/hooks/usePushNotifications.ts Push subscription management hook pushManager.subscribe
path provides contains
ui/src/components/NotificationPermissionPrompt.tsx Permission request UI Stay in the loop
from to via pattern
ui/src/hooks/usePushNotifications.ts server/src/routes/push.ts POST /api/push/subscribe with subscription JSON /api/push/subscribe
from to via pattern
server/src/services/pushService.ts web-push webPush.sendNotification() sendNotification
from to via pattern
server/src/routes/push.ts packages/db/src/schema/push_subscriptions.ts Drizzle insert/delete on push_subscriptions table 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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):

// 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):

// 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)
  1. Create packages/db/src/migrations/0055_create_push_subscriptions.sql:

    • Follow the same pattern as 0053_create_chat_files.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");
      
  2. Update packages/db/src/schema/index.ts:

    • Add export * from "./push_subscriptions";
  3. 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<string, string> }): 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)
  4. 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
  5. 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" <acceptance_criteria>
    • 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 </acceptance_criteria> 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)
  1. Create ui/src/hooks/usePushNotifications.ts:

    • Export function usePushNotifications(): { isSupported: boolean; permission: NotificationPermission | "unsupported"; subscribe: () => Promise<void>; unsubscribe: () => Promise<void> }
    • isSupported: "serviceWorker" in navigator && "PushManager" in window && "Notification" in window
    • permission: useState<NotificationPermission>(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)
  2. 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" — <Button size="sm" onClick={subscribe}> (accent/primary)
      • "Not now" — <Button variant="ghost" size="sm"> — stores "true" in localStorage.setItem("nexus.notifPromptDismissed", "true")
  3. Update ui/src/components/ChatPanel.tsx:

    • Import NotificationPermissionPrompt
    • Track agentResponseCount — count assistant messages loaded in the current session. A simple useRef counter that increments whenever a new assistant message arrives (from streaming or history load) works. Alternatively, derive from the messages array length where role === "assistant".
    • Render <NotificationPermissionPrompt agentResponseCount={agentResponseCount} /> alongside the other banners

NOTE: ChatPanel.tsx was also modified by plans 26-02 and 26-03. This plan depends on both completing first. Read the current state of ChatPanel.tsx (post-26-03) before making changes. grep -q "pushManager.subscribe" ui/src/hooks/usePushNotifications.ts && grep -q "Stay in the loop" ui/src/components/NotificationPermissionPrompt.tsx && grep -q "NotificationPermissionPrompt" ui/src/components/ChatPanel.tsx && grep -q "vapid-public-key" ui/src/api/push.ts && echo "PASS" <acceptance_criteria> - push.ts API client has getVapidPublicKey, subscribe, unsubscribe methods - usePushNotifications.ts checks isSupported, requests permission, subscribes via SW pushManager - usePushNotifications.ts includes urlBase64ToUint8Array utility - NotificationPermissionPrompt.tsx shows after 3rd agent response (agentResponseCount >= 3) - NotificationPermissionPrompt.tsx checks nexus.notifPromptDismissed localStorage - NotificationPermissionPrompt.tsx has "Stay in the loop" heading and "Allow notifications" / "Not now" buttons - ChatPanel.tsx renders the prompt with correct agentResponseCount - pnpm --filter @paperclipai/ui build succeeds </acceptance_criteria> Push notification system complete end-to-end. Client subscribes via SW pushManager, server stores subscriptions, NotificationPermissionPrompt appears after engagement gate.

- `pnpm --filter @paperclipai/ui build` succeeds - Push subscription DB table uses pgTable with uuid/timestamp (pg-core, not sqlite-core) - Migration file 0055 exists with CREATE TABLE SQL - Server VAPID init is graceful (no crash if env vars missing) - Push routes mounted at `/api/push` - Client hook subscribes via SW pushManager - Permission prompt respects engagement gate and localStorage dismiss

<success_criteria> Push notifications wired end-to-end. Server stores subscriptions in PostgreSQL, sends via web-push, auto-cleans stale 410 endpoints. Client subscribes after permission grant. Prompt appears after 3 agent responses. System degrades gracefully when VAPID keys not configured. </success_criteria>

After completion, create `.planning/phases/26-pwa-performance/26-04-SUMMARY.md`