302 lines
17 KiB
Markdown
302 lines
17 KiB
Markdown
---
|
|
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"
|
|
---
|
|
|
|
<objective>
|
|
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.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<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
|
|
|
|
<interfaces>
|
|
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
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create push_subscriptions DB schema, migration, pushService, and push routes</name>
|
|
<files>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</files>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<action>
|
|
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<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)
|
|
|
|
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
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Push notification backend complete: DB table with pg-core schema, SQL migration, VAPID service, API routes, app mounting. Stale subscriptions auto-cleaned.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create push API client, usePushNotifications hook, and NotificationPermissionPrompt</name>
|
|
<files>ui/src/api/push.ts, ui/src/hooks/usePushNotifications.ts, ui/src/components/NotificationPermissionPrompt.tsx, ui/src/components/ChatPanel.tsx</files>
|
|
<read_first>
|
|
- 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
|
|
</read_first>
|
|
<action>
|
|
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<void>` — POST `/api/push/subscribe` with `{ ...subscription.toJSON(), ...meta }`
|
|
c. `unsubscribe(endpoint: string): Promise<void>` — 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<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)
|
|
|
|
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" — `<Button size="sm" onClick={subscribe}>` (accent/primary)
|
|
- "Not now" — `<Button variant="ghost" size="sm">` — stores `"true"` in `localStorage.setItem("nexus.notifPromptDismissed", "true")`
|
|
|
|
4. 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.
|
|
</action>
|
|
<verify>
|
|
<automated>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"</automated>
|
|
</verify>
|
|
<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>
|
|
<done>Push notification system complete end-to-end. Client subscribes via SW pushManager, server stores subscriptions, NotificationPermissionPrompt appears after engagement gate.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `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
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/26-pwa-performance/26-04-SUMMARY.md`
|
|
</output>
|