17 KiB
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 26-pwa-performance | 04 | execute | 4 |
|
|
true |
|
|
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)
-
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");
- Follow the same pattern as
-
Update
packages/db/src/schema/index.ts:- Add
export * from "./push_subscriptions";
- Add
-
Create
server/src/services/pushService.ts:- Import
webPushfrom"web-push" - Import
eqfrom"drizzle-orm" - Import
pushSubscriptionsfrom the schema - Type
dbparameter as the Drizzle database type used across the codebase (check chat.ts for the pattern) initVapid()function: callwebPush.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(): returnsprocess.env.VAPID_PUBLIC_KEY || nullsaveSubscription(db, { endpoint, p256dh, auth, userId?, companyId?, deviceLabel? }): generate UUID viacrypto.randomUUID(), insert intopushSubscriptions, return the idremoveSubscription(db, endpoint): delete frompushSubscriptionswhereendpointmatchessendPushToAll(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, callwebPush.sendNotification({ endpoint, keys: { p256dh, auth } }, JSON.stringify(payload))c. On410 Goneor404 Not Foundresponse, 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)
- Import
-
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 configuredPOST /subscribe— body:{ endpoint, keys: { p256dh, auth }, userId?, companyId?, deviceLabel? }— callssaveSubscription, returns 201DELETE /subscribe— body:{ endpoint }— callsremoveSubscription, returns 204
- Export
-
Update
server/src/app.ts:- Import
pushRoutesfrom"./routes/push" - Import
initVapidfrom"./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.tsusespgTablefromdrizzle-orm/pg-core(NOT sqlite-core)push_subscriptions.tsusesuuid("id").primaryKey().defaultRandom()for id columnpush_subscriptions.tsusestimestamp("created_at", { withTimezone: true }).notNull().defaultNow()for timestamppush_subscriptions.tsuses object-syntax(table) => ({})for index callback0055_create_push_subscriptions.sqlmigration exists with CREATE TABLE and CREATE INDEXschema/index.tsexports push_subscriptionspushService.tsinitializes VAPID only if env vars are set (graceful skip)pushService.tsdeletes stale subscriptions on 410/404 responsepush.tsroutes: GET/vapid-public-key, POST/subscribe, DELETE/subscribeapp.tsmounts push routes and callsinitVapid()pnpm --filter @paperclipai/server buildsucceeds (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.
- Import
-
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 windowpermission:useState<NotificationPermission>(Notification.permission ?? "default")— poll viauseEffectif permission might changesubscribecallback: a. Request permission:const perm = await Notification.requestPermission()— update state b. Ifperm !== "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.readye. Subscribe:const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey) })f. Send to server:await pushApi.subscribe(sub.toJSON())unsubscribecallback: a. Get SW registration and existing subscription b. Callsub.unsubscribe()c. CallpushApi.unsubscribe(sub.endpoint)- Include
urlBase64ToUint8Arrayutility function (per RESEARCH code example — converts base64 VAPID key to Uint8Array)
- Export
-
Create
ui/src/components/NotificationPermissionPrompt.tsx:- Import
usePushNotificationshook - Internal state:
dismissed(fromlocalStorage.getItem("nexus.notifPromptDismissed")) - Props:
{ agentResponseCount: number }(engagement gate — show after 3rd agent response) - Show conditions (ALL must be true):
a.
isSupported === trueb.permission === "default"(not already granted or denied) c.!dismissed(not previously dismissed — stored innexus.notifPromptDismissedlocalStorage 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-4b. Heading: "Stay in the loop" intext-sm font-semiboldc. Body: "Get notified when your agents complete tasks or need input." intext-xs text-muted-foreground mt-1d. 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"inlocalStorage.setItem("nexus.notifPromptDismissed", "true")
- "Allow notifications" —
- Import
-
Update
ui/src/components/ChatPanel.tsx:- Import
NotificationPermissionPrompt - Track
agentResponseCount— count assistant messages loaded in the current session. A simpleuseRefcounter that increments whenever a new assistant message arrives (from streaming or history load) works. Alternatively, derive from the messages array length whererole === "assistant". - Render
<NotificationPermissionPrompt agentResponseCount={agentResponseCount} />alongside the other banners
- Import
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.
<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`