docs(05): research phase web-push-notifications
This commit is contained in:
@@ -0,0 +1,875 @@
|
|||||||
|
# Phase 5: Web Push Notifications — Research
|
||||||
|
|
||||||
|
**Researched:** 2026-06-09
|
||||||
|
**Domain:** Web Push (VAPID), vite-plugin-pwa injectManifest, iOS push reliability, Node.js scheduler, MariaDB schema migration
|
||||||
|
**Confidence:** HIGH (codebase facts) / MEDIUM (library APIs via Context7)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **D-01:** Coalesce list-change pushes per list within ~30–60s window. Reorder (`position`) changes do NOT push.
|
||||||
|
- **D-02:** Event notifications show specifics (title, time, action). List pings stay generic (actor + list + change count, no item text).
|
||||||
|
- **D-03:** Name the actor in every change notification. Two-person household.
|
||||||
|
- **D-04:** Event-change trigger = meaningful changes only (new, delete, time/date/title/location changes). Description-only edits are silent.
|
||||||
|
- **D-05:** Reminders fire for SHARED Family-calendar events only (is_shared=1). Native device calendar apps cover personal events.
|
||||||
|
- **D-06:** Fixed ~15 min lead time for v1. No per-event offset.
|
||||||
|
- **D-07:** All-day events get no reminder.
|
||||||
|
- **D-08:** Contextual permission prompt right after PWA install or first installed launch; trigger pushManager.subscribe() on a tap gesture.
|
||||||
|
- **D-09:** Single master on/off toggle for v1.
|
||||||
|
- **D-10:** Dead-subscription recovery = silent auto re-subscribe on app open if OS permission is still granted. Surface UI only if OS permission itself was revoked.
|
||||||
|
- **D-11:** iOS reliability is mandatory from day one: subscription health-check + event.waitUntil() in SW + every push MUST display a visible notification.
|
||||||
|
- **D-12:** In-memory EventEmitter fan-out, no Redis. API is a single Node process. Push dispatch hooks same publish points as SSE.
|
||||||
|
- **D-13:** Broker is the only Fastmail I/O boundary. Notification code reads from MariaDB cache / poller / outbox — no tsdav in notification code.
|
||||||
|
- **D-14:** react-router is installed. Tap targets use real URLs for deep-linking.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
|
||||||
|
- No quiet-hours / DND in v1.
|
||||||
|
- Tap-to-open deep-link targets (obvious mapping).
|
||||||
|
- Service-worker strategy: switch generateSW → injectManifest with custom SW; preserve Workbox precache + autoUpdate.
|
||||||
|
- VAPID key generation + storage strategy.
|
||||||
|
- Push-subscription table schema (member-count-agnostic per D-18).
|
||||||
|
- Reminder-scheduler mechanism (cron/interval scanning shared-calendar timed events in MariaDB cache).
|
||||||
|
- Coalescing debounce implementation.
|
||||||
|
- Event-change detection trigger points (poller vs outbox-confirm).
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
|
||||||
|
- Quiet hours / Do-Not-Disturb
|
||||||
|
- Per-event / custom reminder lead time
|
||||||
|
- Per-category opt-out (reminder / event-change / list-change toggles)
|
||||||
|
- Reminders for personal-calendar events
|
||||||
|
- Notifying on the member's own changes
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| NOTIF-01 | User receives a Web Push reminder before an event starts | Reminder scheduler (node-cron interval scanning shared-calendar timed events at dtstart_utc in MariaDB cache), web-push sendNotification, SW push event with event.waitUntil() |
|
||||||
|
| NOTIF-02 | User receives a Web Push alert when the other member changes a shared list | Hook publishListEvent in listEmitter.ts, coalescing debounce (30–60s per list), suppress actor's own subscription |
|
||||||
|
| NOTIF-03 | User receives a Web Push alert when an event is added or changed | Hook syncCalendar (poller) and outboxWorker (on success + targeted re-sync), filter meaningful fields, suppress actor's own subscription |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 5 delivers Web Push for three distinct triggers — event reminders (NOTIF-01), event changes (NOTIF-03), and list changes (NOTIF-02) — across iOS and Android. The technical implementation splits cleanly into four areas: server-side VAPID dispatch, a new reminder scheduler, PWA service-worker migration, and frontend subscription lifecycle.
|
||||||
|
|
||||||
|
The most critical constraint is iOS reliability. iOS silently revokes a push subscription after approximately three pushes that do not display a visible notification. The mandatory mitigations (event.waitUntil(), every push shows a notification, subscription health-check on app open) must be present from the first commit. Missing any one of them causes silent subscription death that the user cannot observe.
|
||||||
|
|
||||||
|
The second critical constraint is the service-worker migration. The existing vite-plugin-pwa config uses `generateSW` which auto-generates the entire SW. Adding `push` and `notificationclick` handlers requires switching to `injectManifest` with a custom SW source file. The migration must preserve the existing Workbox precache manifest injection, the `/callback` denylist (T-03-20), and the `autoUpdate` behavior — all of which are currently handled automatically and must be explicitly re-declared in the custom SW.
|
||||||
|
|
||||||
|
**Primary recommendation:** Implement in this order — (1) migrate SW to injectManifest + add push/notificationclick skeleton, (2) add push-subscription table + API routes, (3) wire dispatch to listEmitter + sync/outbox hooks, (4) add reminder scheduler, (5) add PWA permission prompt + settings UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Primary Tier | Secondary Tier | Rationale |
|
||||||
|
|------------|-------------|----------------|-----------|
|
||||||
|
| VAPID key storage | API / Backend | — | Private key must never reach browser; stored in .env / DB |
|
||||||
|
| Push subscription storage | API / Backend (MariaDB) | — | Subscriptions are server-side state; browser only holds in pushManager |
|
||||||
|
| Push dispatch (sendNotification) | API / Backend | — | Server-side only; web-push library runs in Node.js |
|
||||||
|
| Reminder scheduling | API / Backend (node-cron) | — | Timer + DB query; no browser involvement |
|
||||||
|
| Coalescing debounce for list pushes | API / Backend | — | Debounce runs on publish events in the API process |
|
||||||
|
| Event-change detection | API / Backend (poller + outbox) | — | Reads MariaDB cache; D-13 prohibits tsdav in notification code |
|
||||||
|
| SW push event + showNotification | Browser / Service Worker | — | push event fires in SW; must call event.waitUntil(showNotification()) |
|
||||||
|
| SW notificationclick (deep-link) | Browser / Service Worker | — | Open /calendar or /lists/:id via clients.openWindow() |
|
||||||
|
| Permission request lifecycle | Browser / Client (React hook) | — | Must be in tap handler; iOS requires user gesture |
|
||||||
|
| Subscription persist / health-check | Browser / Client (React hook) | API / Backend | Hook reads pushManager, POSTs subscription to API |
|
||||||
|
| Settings toggle (master on/off) | Frontend / React PWA | API / Backend | UI toggle; DELETE subscription via API |
|
||||||
|
| Permission-denied banner | Frontend / React PWA | — | Read Notification.permission; no API call needed |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| web-push | 3.6.7 | VAPID key generation + push dispatch | Listed in CLAUDE.md; only maintained Node.js VAPID push library; 5M+ weekly downloads [VERIFIED: npm registry] |
|
||||||
|
| @types/web-push | 3.6.4 | TypeScript types for web-push | Official DefinitelyTyped types; required for strict-mode TS [VERIFIED: npm registry] |
|
||||||
|
| workbox-precaching | 7.4.1 | Precache manifest in custom SW | Required by vite-plugin-pwa injectManifest; currently handled auto by generateSW [VERIFIED: npm registry] |
|
||||||
|
| workbox-core | 7.x | clientsClaim + skipWaiting for autoUpdate | Required for autoUpdate behavior in injectManifest mode [ASSUMED — workbox-core is the peer of workbox-precaching; version matches Workbox 7] |
|
||||||
|
|
||||||
|
### Supporting
|
||||||
|
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| node-cron | 4.2.1 | Reminder scheduler | Already installed in apps/api; used by poller and outboxWorker [VERIFIED: codebase] |
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# API
|
||||||
|
pnpm --filter @familysync/api add web-push
|
||||||
|
pnpm --filter @familysync/api add -D @types/web-push
|
||||||
|
|
||||||
|
# PWA (devDependencies — workbox is bundled into SW at build time)
|
||||||
|
pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version verification (run at implementation time):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm view web-push version # confirmed 3.6.7
|
||||||
|
npm view @types/web-push version # confirmed 3.6.4
|
||||||
|
npm view workbox-precaching version # confirmed 7.4.1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Package Legitimacy Audit
|
||||||
|
|
||||||
|
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|
||||||
|
|---------|----------|-----|-----------|-------------|---------|-------------|
|
||||||
|
| web-push | npm | ~9 yrs (2024-01-16 last pub) | 5.09M/wk | github.com/web-push-libs/web-push | OK | Approved |
|
||||||
|
| @types/web-push | npm | ~8 yrs (2024-10-22 last pub) | 1.68M/wk | github.com/DefinitelyTyped/DefinitelyTyped | OK | Approved |
|
||||||
|
| workbox-precaching | npm | ~8 yrs (2026-05-04 last pub) | 7.92M/wk | github.com/googlechrome/workbox | OK | Approved |
|
||||||
|
|
||||||
|
**Packages removed due to SLOP verdict:** none
|
||||||
|
**Packages flagged as suspicious (SUS):** none
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### System Architecture Diagram
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (PWA) API (Node.js / Hono)
|
||||||
|
───────────────────────────── ──────────────────────────────────────────
|
||||||
|
[InstallPrompt/PushPermissionPrompt]
|
||||||
|
│ tap: requestPermission()
|
||||||
|
↓
|
||||||
|
[pushManager.subscribe(vapidPublicKey)]
|
||||||
|
│ PushSubscription {endpoint, keys}
|
||||||
|
│ POST /api/push/subscription
|
||||||
|
─────────────────────────────────→ [pushRouter]
|
||||||
|
│ INSERT push_subscriptions (userId, endpoint, p256dh, auth)
|
||||||
|
↓
|
||||||
|
[MariaDB: push_subscriptions]
|
||||||
|
|
||||||
|
── Triggers (server-side) ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[node-cron every 1 min] [poller/sync.ts — on calendarEvents upsert]
|
||||||
|
│ SELECT shared timed events │ detect meaningful change (new/updated uid)
|
||||||
|
│ WHERE dtstart_utc BETWEEN │ skip actor's subscription
|
||||||
|
│ NOW()+14min AND NOW()+16min │
|
||||||
|
↓ ↓
|
||||||
|
[reminderScheduler.ts] [eventChangeDispatcher.ts]
|
||||||
|
│ SELECT push_subscriptions │ SELECT push_subscriptions
|
||||||
|
│ WHERE userId != event.userId? │ WHERE userId NOT IN (actor)
|
||||||
|
│ (all members for shared events) │
|
||||||
|
↓ ↓
|
||||||
|
[pushDispatcher.ts] ←─────────────[listChangeDispatcher.ts (coalesced)]
|
||||||
|
│ webpush.sendNotification() ↑
|
||||||
|
│ payload: {web_push:8030, [publishListEvent hook in listEmitter.ts]
|
||||||
|
│ notification:{title,body, │ debounce 30-60s per (listId, actorId)
|
||||||
|
│ navigate, ...}} │ suppress actor's own subscription
|
||||||
|
│
|
||||||
|
│ on 410/404 → DELETE push_subscriptions (prune expired)
|
||||||
|
│ on success → subscription stays
|
||||||
|
↓
|
||||||
|
[Push Service (APNs/FCM)]
|
||||||
|
↓
|
||||||
|
[Browser / iOS SW]
|
||||||
|
|
||||||
|
── PWA Service Worker (sw.ts) ──────────────────────────────────────────────
|
||||||
|
[push event]
|
||||||
|
└→ event.waitUntil(
|
||||||
|
self.registration.showNotification(data.notification.title, {
|
||||||
|
body, tag, data: {url}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
[notificationclick event]
|
||||||
|
└→ clients.openWindow(event.notification.data.url)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Project Structure
|
||||||
|
|
||||||
|
New files this phase:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api/src/
|
||||||
|
├── routes/
|
||||||
|
│ └── push.ts # POST /api/push/subscription, DELETE, GET /api/push/vapid-public-key
|
||||||
|
├── lib/
|
||||||
|
│ ├── pushDispatcher.ts # webpush.sendNotification wrapper + 410/404 pruning
|
||||||
|
│ └── pushCoalescer.ts # per-(listId,actorId) debounce for list-change pushes
|
||||||
|
├── broker/
|
||||||
|
│ └── reminderScheduler.ts # node-cron 1-min interval: scan shared timed events ±1min window
|
||||||
|
└── db/schema.ts # add push_subscriptions table
|
||||||
|
|
||||||
|
apps/pwa/src/
|
||||||
|
├── sw.ts # NEW custom SW: precacheAndRoute + push + notificationclick
|
||||||
|
├── components/
|
||||||
|
│ ├── PushPermissionPrompt.tsx
|
||||||
|
│ ├── SettingsSheet.tsx
|
||||||
|
│ └── PermissionDeniedBanner.tsx
|
||||||
|
└── hooks/
|
||||||
|
└── usePushSubscription.ts # subscribe/unsubscribe/health-check lifecycle
|
||||||
|
|
||||||
|
apps/api/src/db/migrations/
|
||||||
|
└── 0003_push_subscriptions.sql # generated by drizzle-kit generate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: web-push VAPID dispatch (TypeScript / ESM)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://github.com/web-push-libs/web-push/blob/master/README.md
|
||||||
|
import webpush from 'web-push'
|
||||||
|
|
||||||
|
// Call once at API startup (index.ts isMainModule() guard)
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
'mailto:admin@familysync.bergerhouse.net',
|
||||||
|
process.env.VAPID_PUBLIC_KEY!,
|
||||||
|
process.env.VAPID_PRIVATE_KEY!,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatch helper — prunes expired subscriptions on 410/404
|
||||||
|
async function dispatchPush(
|
||||||
|
subscription: { endpoint: string; p256dh: string; auth: string },
|
||||||
|
payload: object,
|
||||||
|
dbRowId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const sub = {
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: { p256dh: subscription.p256dh, auth: subscription.auth },
|
||||||
|
}
|
||||||
|
const body = JSON.stringify({
|
||||||
|
web_push: 8030,
|
||||||
|
notification: payload,
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(sub, body, {
|
||||||
|
TTL: 300, // 5 min: notification has already expired if not delivered soon
|
||||||
|
urgency: 'normal',
|
||||||
|
})
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const statusCode = (err as { statusCode?: number }).statusCode
|
||||||
|
if (statusCode === 410 || statusCode === 404) {
|
||||||
|
// Subscription expired — delete from DB to avoid future failed sends
|
||||||
|
await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, dbRowId))
|
||||||
|
}
|
||||||
|
// Other errors: log and continue (transient failures; next send will retry)
|
||||||
|
console.error('[pushDispatcher] sendNotification error:', statusCode, (err as Error).message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Custom Service Worker (sw.ts) — injectManifest
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
|
||||||
|
import { precacheAndRoute } from 'workbox-precaching'
|
||||||
|
import { clientsClaim } from 'workbox-core'
|
||||||
|
|
||||||
|
declare let self: ServiceWorkerGlobalScope
|
||||||
|
|
||||||
|
// autoUpdate behavior: claim all clients immediately on activate
|
||||||
|
self.skipWaiting()
|
||||||
|
clientsClaim()
|
||||||
|
|
||||||
|
// Inject Workbox precache manifest (plugin populates self.__WB_MANIFEST at build time)
|
||||||
|
precacheAndRoute(self.__WB_MANIFEST)
|
||||||
|
|
||||||
|
// CRITICAL: every push MUST call showNotification (D-11 / iOS requirement)
|
||||||
|
self.addEventListener('push', (event: PushEvent) => {
|
||||||
|
let title = 'FamilySync'
|
||||||
|
let options: NotificationOptions = { body: 'You have a new notification' }
|
||||||
|
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const data = event.data.json() as {
|
||||||
|
notification?: { title?: string; body?: string; navigate?: string }
|
||||||
|
title?: string
|
||||||
|
body?: string
|
||||||
|
tag?: string
|
||||||
|
data?: { url?: string }
|
||||||
|
}
|
||||||
|
// Support both Declarative Web Push format (iOS 18.4+) and legacy format
|
||||||
|
const notif = data.notification ?? data
|
||||||
|
title = notif.title ?? title
|
||||||
|
options = {
|
||||||
|
body: notif.body ?? options.body,
|
||||||
|
tag: (data as { tag?: string }).tag ?? undefined,
|
||||||
|
data: { url: (notif as { navigate?: string }).navigate ?? (data as { data?: { url?: string } }).data?.url ?? '/' },
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed payload — still show a generic notification (iOS: never drop silently)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// event.waitUntil is MANDATORY — iOS revokes subscription after ~3 silent pushes (D-11)
|
||||||
|
event.waitUntil(self.registration.showNotification(title, options))
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
||||||
|
event.notification.close()
|
||||||
|
const url: string = (event.notification.data as { url?: string })?.url ?? '/'
|
||||||
|
event.waitUntil(
|
||||||
|
(self.clients as Clients).matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
// Focus existing window if already open
|
||||||
|
for (const client of clientList) {
|
||||||
|
if ('url' in client && (client as WindowClient).url === url && 'focus' in client) {
|
||||||
|
return (client as WindowClient).focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (self.clients as Clients).openWindow(url)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: vite.config.ts migration to injectManifest
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
|
||||||
|
VitePWA({
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'src',
|
||||||
|
filename: 'sw.ts',
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectManifest: {
|
||||||
|
// Preserve existing SW denylist behavior (T-03-20: /callback must not be precached)
|
||||||
|
globIgnores: ['**/node_modules/**', '**/callback**'],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
// ... same manifest config as current generateSW setup ...
|
||||||
|
},
|
||||||
|
// Note: navigateFallback moves from workbox: {} to injectManifest: {} or is handled
|
||||||
|
// directly in the custom SW via WorkboxRouter if needed
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Push subscription schema (Drizzle, MariaDB)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/db/schema.ts addition
|
||||||
|
import { varchar, text, timestamp, int, mysqlTable, index, unique } from 'drizzle-orm/mysql-core'
|
||||||
|
|
||||||
|
export const pushSubscriptions = mysqlTable(
|
||||||
|
'push_subscriptions',
|
||||||
|
{
|
||||||
|
id: int().primaryKey().autoincrement(),
|
||||||
|
userId: int('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
endpoint: text('endpoint').notNull(),
|
||||||
|
p256dh: text('p256dh').notNull(),
|
||||||
|
auth: varchar('auth', { length: 256 }).notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
// One endpoint per user (a member can have multiple devices, but endpoint is globally unique)
|
||||||
|
unique('uniq_push_endpoint').on(t.endpoint),
|
||||||
|
index('idx_push_subscriptions_user_id').on(t.userId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MUST use generate+migrate, never db:push (drizzle-mariadb-push-unsafe.md)
|
||||||
|
pnpm --filter @familysync/api db:generate
|
||||||
|
pnpm --filter @familysync/api db:migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration file will be generated at `apps/api/src/db/migrations/0003_<name>.sql`.
|
||||||
|
|
||||||
|
### Pattern 5: Reminder Scheduler (node-cron, 1-min interval)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/broker/reminderScheduler.ts
|
||||||
|
import { schedule } from 'node-cron'
|
||||||
|
import { and, eq, gte, lte, isNull, not } from 'drizzle-orm'
|
||||||
|
import { db } from '../db/client.js'
|
||||||
|
import { calendars, calendarEvents, pushSubscriptions } from '../db/schema.js'
|
||||||
|
|
||||||
|
// Fire once per minute; scan window: [now+14min, now+16min] (D-06: 15-min lead)
|
||||||
|
export function startReminderScheduler(): void {
|
||||||
|
schedule('* * * * *', async () => {
|
||||||
|
const now = new Date()
|
||||||
|
const windowStart = new Date(now.getTime() + 14 * 60 * 1000)
|
||||||
|
const windowEnd = new Date(now.getTime() + 16 * 60 * 1000)
|
||||||
|
|
||||||
|
// D-05: shared events only; D-07: timed events only (allDay=false)
|
||||||
|
const events = await db
|
||||||
|
.select({ uid: calendarEvents.uid, title: calendarEvents.uid /* swap for title field */ })
|
||||||
|
.from(calendarEvents)
|
||||||
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(calendars.isShared, true),
|
||||||
|
eq(calendarEvents.allDay, false),
|
||||||
|
gte(calendarEvents.dtstartUtc, windowStart),
|
||||||
|
lte(calendarEvents.dtstartUtc, windowEnd),
|
||||||
|
not(isNull(calendarEvents.dtstartUtc)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// Get all subscriptions for all users (shared event — notify all members)
|
||||||
|
const subs = await db.select().from(pushSubscriptions)
|
||||||
|
for (const sub of subs) {
|
||||||
|
await dispatchPush(sub, { title: event.uid, body: 'Starts in 15 min', ... }, sub.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critical schema note:** `calendarEvents` does NOT have a `title` column — only `uid`, `rawVevent`, and `dtstartUtc`. The scheduler must extract the VEVENT SUMMARY from `rawVevent` using ical.js, or the schema must be extended with a `title` column (recommended — avoids ical.js parsing on every reminder fire).
|
||||||
|
|
||||||
|
### Pattern 6: List-change coalescing debounce
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/api/src/lib/pushCoalescer.ts
|
||||||
|
const pendingCoalesced = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>()
|
||||||
|
|
||||||
|
export function coalesceListPush(
|
||||||
|
listId: number,
|
||||||
|
actorId: number,
|
||||||
|
actorName: string,
|
||||||
|
listName: string,
|
||||||
|
dispatch: (payload: object, excludeUserId: number) => void,
|
||||||
|
windowMs = 45_000,
|
||||||
|
): void {
|
||||||
|
const key = `${listId}:${actorId}`
|
||||||
|
const existing = pendingCoalesced.get(key)
|
||||||
|
if (existing) {
|
||||||
|
existing.count++
|
||||||
|
clearTimeout(existing.timer)
|
||||||
|
}
|
||||||
|
const entry = existing ?? { count: 1, timer: null! }
|
||||||
|
entry.timer = setTimeout(() => {
|
||||||
|
pendingCoalesced.delete(key)
|
||||||
|
dispatch(
|
||||||
|
{
|
||||||
|
title: `${actorName} updated ${listName}`,
|
||||||
|
body: `${entry.count} change${entry.count === 1 ? '' : 's'}`,
|
||||||
|
navigate: `/lists/${listId}`,
|
||||||
|
},
|
||||||
|
actorId, // D-03: suppress own notification
|
||||||
|
)
|
||||||
|
}, windowMs)
|
||||||
|
if (!existing) pendingCoalesced.set(key, entry)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 7: usePushSubscription hook (React PWA)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// apps/pwa/src/hooks/usePushSubscription.ts
|
||||||
|
// Manages: subscribe, unsubscribe, health-check on mount (D-10)
|
||||||
|
export function usePushSubscription() {
|
||||||
|
// On mount: if permission granted but no subscription → silently re-subscribe (D-10)
|
||||||
|
useEffect(() => {
|
||||||
|
if (Notification.permission !== 'granted') return
|
||||||
|
navigator.serviceWorker.ready.then(async (reg) => {
|
||||||
|
const existing = await reg.pushManager.getSubscription()
|
||||||
|
if (!existing) {
|
||||||
|
// Silent re-subscribe (D-10) — no user gesture needed (permission already granted)
|
||||||
|
await subscribeAndPost(reg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// subscribe: must be called inside a tap handler (D-08 / iOS requirement)
|
||||||
|
async function subscribe(reg: ServiceWorkerRegistration): Promise<void> {
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
|
||||||
|
})
|
||||||
|
await fetch('/api/push/subscription', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(sub.toJSON()),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unsubscribe(): Promise<void> {
|
||||||
|
const reg = await navigator.serviceWorker.ready
|
||||||
|
const sub = await reg.pushManager.getSubscription()
|
||||||
|
if (sub) await sub.unsubscribe()
|
||||||
|
await fetch('/api/push/subscription', { method: 'DELETE', credentials: 'include' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { subscribe, unsubscribe }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Calling pushManager.subscribe() outside a user gesture:** iOS silently fails. Always call inside onClick/onTap handler, never on component mount or useEffect.
|
||||||
|
- **Silent pushes (no showNotification in push handler):** iOS revokes subscription after ~3 silent pushes. event.waitUntil(showNotification(...)) is mandatory on every push event, even if payload is malformed.
|
||||||
|
- **Using db:push for schema migration:** drizzle-kit push emits false destructive diff on populated MariaDB (truncates tables). Always use `db:generate` + `db:migrate`.
|
||||||
|
- **Using generateSW with custom push handler:** generateSW auto-generates the entire SW from options only — there is no hook to inject push event listeners. Must switch to injectManifest.
|
||||||
|
- **Storing VAPID private key in code / git:** Store in .env (VAPID_PRIVATE_KEY). Never commit.
|
||||||
|
- **Fan-out to all users for a list change:** Only fan out to users who can access the list (list_shares join table). Use same access check as SSE route.
|
||||||
|
- **Duplicate reminder pushes:** The 1-min scheduler with a ±1-min window will fire twice for an event if it falls exactly at the boundary. Deduplicate via a reminder_sent flag or a separate `sent_reminders` table keyed by (eventUid, scheduledAt bucket).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| VAPID signing + encryption | Custom crypto | web-push | RFC 8292 + Message Encryption for Web Push; 40+ lines of crypto primitives per send |
|
||||||
|
| Push payload encryption | Manual AES-128-GCM | web-push.sendNotification | Handles p256dh key agreement + content encryption per IETF RFC 8291 |
|
||||||
|
| Service worker precache manifest | Manual file list | workbox-precaching + self.__WB_MANIFEST | Build-time injection; stale hash mismatches cause update failures |
|
||||||
|
| VAPID key generation | crypto.generateKeyPair | webpush.generateVAPIDKeys() or web-push CLI | Returns URL-safe Base64 directly; format required by push services |
|
||||||
|
| Subscription expiry cleanup | Custom cron | 410/404 error handler in dispatchPush | Push services send 410 exactly when subscription is gone; polling misses edge cases |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Runtime State Inventory
|
||||||
|
|
||||||
|
> Not a rename/refactor phase. Section omitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: iOS subscription silently revoked after ~3 silent pushes
|
||||||
|
**What goes wrong:** Push notifications stop arriving on iOS with no error. The subscription endpoint still exists in the DB. The push service returns 200 but the notification never appears.
|
||||||
|
**Why it happens:** iOS Safari enforces that every push event results in a visible notification. Three consecutive push events without showNotification() cause APNs to mark the subscription dead.
|
||||||
|
**How to avoid:** Every push event handler MUST call event.waitUntil(self.registration.showNotification(...)) — even for malformed payloads (fall back to a generic message). No silent pushes, ever.
|
||||||
|
**Warning signs:** Users stop receiving notifications after a period of working correctly. DB shows no pruned subscriptions (410 errors never appear because the subscription is dead but not explicitly invalidated by APNs).
|
||||||
|
|
||||||
|
### Pitfall 2: pushManager.subscribe() outside a user gesture fails silently on iOS
|
||||||
|
**What goes wrong:** The subscribe call returns a rejected promise or does nothing. No error surfaced to the user.
|
||||||
|
**Why it happens:** iOS requires pushManager.subscribe() to be invoked directly within a user tap event handler — not in a useEffect, not in a setTimeout, not after an await boundary. Any async hop breaks the user-gesture context.
|
||||||
|
**How to avoid:** The "Enable Notifications" button onClick must call pushManager.subscribe() synchronously (before any awaits) or use the existing tap event reference. See D-08 and UI-SPEC surface 1.
|
||||||
|
**Warning signs:** Subscribe works on Android/Chrome but silently fails on iOS.
|
||||||
|
|
||||||
|
### Pitfall 3: vite-plugin-pwa injectManifest — missing workbox-precaching devDependency
|
||||||
|
**What goes wrong:** Build fails with `Cannot find module 'workbox-precaching'` or the SW bundles without precache support.
|
||||||
|
**Why it happens:** In generateSW mode, vite-plugin-pwa bundles Workbox internally. In injectManifest mode, the custom SW source is compiled by Vite — workbox-precaching must be an explicit devDependency.
|
||||||
|
**How to avoid:** `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`
|
||||||
|
**Warning signs:** TypeScript error in sw.ts on `import { precacheAndRoute }`.
|
||||||
|
|
||||||
|
### Pitfall 4: /callback denylist lost after SW migration
|
||||||
|
**What goes wrong:** After migrating to injectManifest, the OIDC /callback route is served from SW cache instead of reaching the server. This causes the login loop bug (T-03-20).
|
||||||
|
**Why it happens:** The existing `workbox.navigateFallbackDenylist` in vite.config.ts only applies to the generateSW strategy. injectManifest does not read from `workbox:` key — the SW must implement the navigation denylist explicitly (via Workbox Router or a fetch event handler checking the URL).
|
||||||
|
**How to avoid:** In sw.ts, add a fetch handler that falls through for /callback and /api/ requests. Or use workbox-routing NavigationRoute with denylist.
|
||||||
|
**Warning signs:** After re-login, the app loops at /callback.
|
||||||
|
|
||||||
|
### Pitfall 5: Duplicate reminder fires for events at the window boundary
|
||||||
|
**What goes wrong:** A user gets two reminder pushes for the same event ~1 minute apart.
|
||||||
|
**Why it happens:** The 1-minute cron runs at T=0 and T=1; an event at dtstart_utc=T+15 falls in both [T+14, T+16] windows.
|
||||||
|
**How to avoid:** Track sent reminders. Simplest approach: a `sent_reminders` table with `(eventUid, reminderBucket CHAR(16))` where bucket = the UTC minute of the scheduled fire. Unique key on (eventUid, reminderBucket) prevents double-insert → skip dispatch on duplicate key.
|
||||||
|
**Warning signs:** Members report receiving identical reminder notifications 1 minute apart.
|
||||||
|
|
||||||
|
### Pitfall 6: calendarEvents has no title column — must parse rawVevent
|
||||||
|
**What goes wrong:** Reminder copy shows the VEVENT UID instead of the event title (e.g. "abc123-def456-..." instead of "Dentist").
|
||||||
|
**Why it happens:** The existing schema stores the VEVENT SUMMARY only in rawVevent (text blob), not as a dedicated indexed column. The scheduler query cannot SELECT a title.
|
||||||
|
**How to avoid:** Add a `title` varchar column to calendar_events (populated during sync from ical.js SUMMARY). This is a new migration (0004) but avoids ical.js parsing on every reminder check. Alternatively, parse rawVevent with ical.js in the scheduler — correct but slower.
|
||||||
|
**Recommended:** Add `title` column to calendar_events schema in Wave 0 (same migration as push_subscriptions, or a separate migration 0004).
|
||||||
|
**Warning signs:** Notification titles are raw UIDs.
|
||||||
|
|
||||||
|
### Pitfall 7: web-push ESM import — requires default import with @types/web-push
|
||||||
|
**What goes wrong:** TypeScript error `Module '"web-push"' has no exported member 'sendNotification'` or runtime error on named import.
|
||||||
|
**Why it happens:** web-push 3.6.7 ships CommonJS only. In an ESM project (apps/api `"type":"module"`), it must be imported as the default export: `import webpush from 'web-push'` (not named imports). @types/web-push provides the types for this pattern.
|
||||||
|
**How to avoid:** Always use `import webpush from 'web-push'` (default import).
|
||||||
|
**Warning signs:** TypeScript compiles but `webpush.setVapidDetails` is undefined at runtime.
|
||||||
|
|
||||||
|
### Pitfall 8: VAPID public key must be served to the PWA as an environment variable
|
||||||
|
**What goes wrong:** pushManager.subscribe() fails with "invalid applicationServerKey" if the PWA uses a hardcoded or stale key.
|
||||||
|
**Why it happens:** The public key must match the private key used by web-push to sign notifications. If they are mismatched (e.g., key regenerated without updating the PWA build), the push service rejects.
|
||||||
|
**How to avoid:** Expose VAPID_PUBLIC_KEY to the Vite build via `VITE_VAPID_PUBLIC_KEY` environment variable. Alternatively, add a GET /api/push/vapid-public-key endpoint (unauthenticated, public). The hook fetches it at subscribe time — this also allows key rotation without a rebuild.
|
||||||
|
**Warning signs:** pushManager.subscribe() rejects with DOMException; existing subscriptions fail to send after key rotation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### VAPID key generation (one-time CLI)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source: https://github.com/web-push-libs/web-push/blob/master/README.md
|
||||||
|
npx web-push generate-vapid-keys --json
|
||||||
|
# → {"publicKey":"B...","privateKey":"I..."}
|
||||||
|
# Add to .env:
|
||||||
|
# VAPID_PUBLIC_KEY=B...
|
||||||
|
# VAPID_PRIVATE_KEY=I...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event payload format (Declarative Web Push + legacy SW compatible)
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Source: https://webkit.org/blog/16535/meet-declarative-web-push/
|
||||||
|
// Dual-format payload: "web_push":8030 enables iOS 18.4+ declarative path;
|
||||||
|
// title/body/tag/data fields are read by the SW push handler for older iOS + Android.
|
||||||
|
{
|
||||||
|
"web_push": 8030,
|
||||||
|
"notification": {
|
||||||
|
"title": "Dentist",
|
||||||
|
"body": "Starts in 15 min",
|
||||||
|
"navigate": "/calendar?date=2026-06-10&event=abc123"
|
||||||
|
},
|
||||||
|
"title": "Dentist",
|
||||||
|
"body": "Starts in 15 min",
|
||||||
|
"tag": "reminder-abc123",
|
||||||
|
"data": { "url": "/calendar?date=2026-06-10&event=abc123" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SW navigateFallback preservation in injectManifest mode
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: https://github.com/vite-pwa/vite-plugin-pwa/blob/main/docs/guide/inject-manifest.md
|
||||||
|
// In sw.ts — replicate the existing navigateFallback + denylist behavior:
|
||||||
|
import { NavigationRoute, registerRoute } from 'workbox-routing'
|
||||||
|
import { createHandlerBoundToURL } from 'workbox-precaching'
|
||||||
|
|
||||||
|
// Deny /callback, /api/*, /health from SW navigation handling (T-03-20)
|
||||||
|
const navigationHandler = createHandlerBoundToURL('/index.html')
|
||||||
|
const navigationRoute = new NavigationRoute(navigationHandler, {
|
||||||
|
denylist: [/^\/callback/, /^\/api\//, /^\/health/],
|
||||||
|
})
|
||||||
|
registerRoute(navigationRoute)
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative — add `workbox-routing` and `workbox-precaching` as devDependencies if this approach is used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Safari required separate APS certificate for push | VAPID (RFC 8292) — same as Chrome/Firefox | Safari 16+ (2022) | Single web-push flow works across all browsers |
|
||||||
|
| Traditional Web Push required service worker JS always | Declarative Web Push — SW optional | iOS 18.4 / Safari 18.4 (April 2025) | Payload format change; SW handler can be simpler |
|
||||||
|
| generateSW sufficient for most PWAs | injectManifest required when custom SW events needed | vite-plugin-pwa 0.12+ | Need to add workbox-precaching explicitly |
|
||||||
|
| CRA for React PWAs | Vite + vite-plugin-pwa | 2023+ (CRA deprecated Feb 2025) | Already using correct stack |
|
||||||
|
|
||||||
|
**Declarative Web Push (iOS 18.4+):**
|
||||||
|
The dual-format payload approach (embedding both `"web_push":8030 + notification{}` AND the legacy `title/body/tag/data` fields in the same JSON body) is backward compatible and handles all iOS versions from 16.4+ through 18.4+ in a single payload. iOS 16.4–18.3 uses the SW push event path; iOS 18.4+ can use the declarative path as a fallback but still processes the SW push event if a SW is installed. [CITED: https://webkit.org/blog/16535/meet-declarative-web-push/]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Codebase Ground-Truth (verified by reading source)
|
||||||
|
|
||||||
|
These facts were confirmed by direct inspection and are the planner's authoritative source. [VERIFIED: codebase]
|
||||||
|
|
||||||
|
### 1. Migration workflow confirmed
|
||||||
|
- `drizzle.config.ts` uses dialect `mysql`, schema at `./src/db/schema.ts`, migrations out to `./src/db/migrations`.
|
||||||
|
- Scripts: `db:generate` → `drizzle-kit generate`; `db:migrate` → `drizzle-kit migrate`; `db:push` exists but is UNSAFE per memory note.
|
||||||
|
- Latest migration: `0002_yielding_mattie_franklin.sql` (adds utf8mb4_bin collation to list_items.rank).
|
||||||
|
- Next migration will be numbered `0003_<generated-name>.sql`.
|
||||||
|
- The `customType` pattern for special column types (e.g., utf8mb4_bin collation) is established in schema.ts and should be used again if needed.
|
||||||
|
|
||||||
|
### 2. web-push NOT installed
|
||||||
|
`apps/api/package.json` does not include `web-push`. Must be installed as part of Wave 0.
|
||||||
|
|
||||||
|
### 3. workbox-precaching NOT installed in apps/pwa
|
||||||
|
`apps/pwa/package.json` has `vite-plugin-pwa ^1.3.0` but neither `workbox-precaching` nor `workbox-core`. Must be installed as devDependencies.
|
||||||
|
|
||||||
|
### 4. vite.config.ts is generateSW mode
|
||||||
|
Confirmed: `VitePWA({ registerType: 'autoUpdate', workbox: { navigateFallback, navigateFallbackDenylist, runtimeCaching: [] } })`. Migration to `injectManifest` MUST:
|
||||||
|
- Preserve the `navigateFallbackDenylist` entries: `/^\/callback/`, `/^\/api\//`, `/^\/health/`
|
||||||
|
- Preserve `runtimeCaching: []` (no API caching)
|
||||||
|
- Re-add `skipWaiting()` + `clientsClaim()` for autoUpdate
|
||||||
|
|
||||||
|
### 5. listEmitter.ts publish points
|
||||||
|
`publishListEvent(listId, event)` is called in the lists routes (confirmed by import chain). Push dispatch for NOTIF-02 hooks this same function. The coalescer wraps the dispatch, not the emitter itself.
|
||||||
|
|
||||||
|
### 6. poller.ts calls syncCalendar on ctag change
|
||||||
|
`runPoll()` calls `syncCalendar(client, davCal, cred.userId)` when ctag changes. This is where external event changes (other member's writes arriving at Fastmail) are detected. NOTIF-03 for external changes should hook here or inside `syncCalendar`.
|
||||||
|
|
||||||
|
### 7. outboxWorker.ts calls triggerTargetedResync on success
|
||||||
|
After a successful CalDAV write, `triggerTargetedResync` runs and calls `syncCalendar`. NOTIF-03 for this-member writes (notifying the OTHER member) should hook at the point where outboxWorker marks a row `done` and the re-sync detects the new/changed event.
|
||||||
|
|
||||||
|
### 8. calendarEvents schema has NO title column
|
||||||
|
`calendarEvents` columns: id, calendarId, uid, etag, objectUrl, rawVevent, dtstartUtc, dtstartDate, allDay, hasRrule, updatedAt. No `title` or `summary` column. The reminder scheduler and event-change dispatcher must either parse `rawVevent` or the schema must be extended (recommended: add `title varchar(500)`).
|
||||||
|
|
||||||
|
### 9. index.ts startup pattern
|
||||||
|
Background workers are started only inside the `isMainModule()` guard to prevent test contamination. The new `startReminderScheduler()` must follow this same pattern.
|
||||||
|
|
||||||
|
### 10. API test pattern
|
||||||
|
Route tests in `tests/routes/` use a real MariaDB connection with `vi.mock('../../src/auth/devBypass.js', ...)` to inject a user. The new `tests/routes/push.test.ts` should follow this pattern. Pure-unit tests (pushDispatcher, pushCoalescer) mock the DB. The `tests/broker/` directory holds worker tests with DB mocking.
|
||||||
|
|
||||||
|
### 11. Test setup truncates list tables only
|
||||||
|
`test/setup.ts` afterEach truncates `list_items`, `list_shares`, `lists`. When `push_subscriptions` is added, the setup must be updated to also truncate it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | web-push 3.6.7 is CommonJS-only; ESM project must use default import `import webpush from 'web-push'` | Standard Stack, Pitfall 7 | Named import works at runtime → no impact; but TypeScript types may differ |
|
||||||
|
| A2 | workbox-core version 7.x is compatible with workbox-precaching 7.4.1 | Standard Stack | Version mismatch causes runtime error in SW; verify with `npm view workbox-core version` |
|
||||||
|
| A3 | iOS 18.4+ Declarative Web Push still processes the SW push event when a SW is installed | Code Examples, dual-format payload | If iOS 18.4+ skips the push event entirely when web_push:8030 is present, the dual-format approach is unnecessary; simpler — no impact on correctness |
|
||||||
|
| A4 | workbox-routing and createHandlerBoundToURL are available in workbox-precaching@7.4.1 suite | Code Examples, navigateFallback | May need `workbox-routing` as a separate devDependency; check if it is a sub-package |
|
||||||
|
| A5 | listEmitter.publishListEvent is called in the route handlers rather than a service layer | Codebase Ground-Truth | If called elsewhere, the coalescer attachment point changes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Does event-change detection require a new syncCalendar hook or a separate table diff?**
|
||||||
|
- What we know: `syncCalendar` does an `onDuplicateKeyUpdate` upsert but does not return which rows changed.
|
||||||
|
- What's unclear: To detect NOTIF-03 changes (new vs modified vs deleted event), the sync must compare old vs new state. The current sync has no "what changed" output.
|
||||||
|
- Recommendation: Add a `pushChangedEvents()` side-effect callback parameter to `syncCalendar` (or a post-sync query comparing `updatedAt` timestamps) that returns newly upserted/deleted events for push dispatch. Alternatively, use a DB trigger or a separate "last_seen_etag" comparison in the scheduler.
|
||||||
|
|
||||||
|
2. **Should VAPID_PUBLIC_KEY be injected at build time (VITE_VAPID_PUBLIC_KEY) or fetched at runtime (GET /api/push/vapid-public-key)?**
|
||||||
|
- What we know: Build-time injection is simpler. Runtime fetch allows key rotation without rebuilds.
|
||||||
|
- What's unclear: How often VAPID keys will rotate in practice.
|
||||||
|
- Recommendation: Use GET /api/push/vapid-public-key endpoint (unauthenticated). Fetched once by usePushSubscription hook before subscribe. Cached in sessionStorage.
|
||||||
|
|
||||||
|
3. **Reminder deduplication strategy: column flag vs separate table?**
|
||||||
|
- What we know: The 1-min cron window approach risks double-firing for events at the window boundary.
|
||||||
|
- What's unclear: Whether a `sent_reminders` table is overkill for a two-person household.
|
||||||
|
- Recommendation: Add a `sentAt` timestamp column to calendarEvents for reminder tracking, or use a simple in-memory Set of `${eventUid}:${minuteBucket}` pairs per process (acceptable for single-process deployment per D-12).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dependency | Required By | Available | Version | Fallback |
|
||||||
|
|------------|------------|-----------|---------|----------|
|
||||||
|
| node-cron | Reminder scheduler | Yes | ^4.2.1 | — (already installed in apps/api) |
|
||||||
|
| MariaDB | push_subscriptions table | Yes | 11.x (Unraid) | — |
|
||||||
|
| node 22 LTS | web-push (VAPID uses Web Crypto) | Yes | 22.x | — (web-push requires Node 18+) |
|
||||||
|
| vite-plugin-pwa 1.3.x | injectManifest strategy | Yes | ^1.3.0 | — (already installed in apps/pwa) |
|
||||||
|
| web-push | Push dispatch | No (not installed) | — | Must install: `pnpm --filter @familysync/api add web-push` |
|
||||||
|
| workbox-precaching / workbox-core | Custom SW build | No (not installed) | — | Must install as devDependencies in apps/pwa |
|
||||||
|
|
||||||
|
**Missing dependencies with no fallback:**
|
||||||
|
- `web-push` in apps/api — blocks all push dispatch
|
||||||
|
- `workbox-precaching` in apps/pwa — blocks SW injectManifest build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Vitest ^4.1.8 |
|
||||||
|
| Config file | apps/api/vitest.config.ts, apps/pwa/vitest.config.ts |
|
||||||
|
| Quick run command | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` |
|
||||||
|
| Full suite command | `pnpm test` (root, runs API suite) |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| NOTIF-01 | Reminder fires for shared timed events ~15min before start | unit (reminderScheduler) | `pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts` | No — Wave 0 |
|
||||||
|
| NOTIF-01 | All-day events do not get reminders (D-07) | unit | same file | No — Wave 0 |
|
||||||
|
| NOTIF-01 | Non-shared events do not get reminders (D-05) | unit | same file | No — Wave 0 |
|
||||||
|
| NOTIF-02 | List-change push fires for other member | unit (pushCoalescer) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts` | No — Wave 0 |
|
||||||
|
| NOTIF-02 | Coalescing collapses burst into one push (D-01) | unit | same file | No — Wave 0 |
|
||||||
|
| NOTIF-02 | Reorder changes do not push (D-01) | unit (lists route) | existing `tests/routes/lists.test.ts` — extend | Partial |
|
||||||
|
| NOTIF-03 | Event-change dispatch on new/updated event (NOTIF-03) | unit (eventChangeDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts` | No — Wave 0 |
|
||||||
|
| NOTIF-03 | Description-only change does NOT push (D-04) | unit | same file | No — Wave 0 |
|
||||||
|
| D-11 | Push subscription POST/DELETE API | integration | `pnpm --filter @familysync/api exec vitest run tests/routes/push.test.ts` | No — Wave 0 |
|
||||||
|
| D-11 | 410/404 from push service prunes subscription | unit (pushDispatcher) | `pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts` | No — Wave 0 |
|
||||||
|
| D-08 | Permission prompt renders after install | PWA component (playwright-cli) | `playwright-cli evaluate "document.querySelector('[aria-label=\"Enable push notifications\"]')"` | No — Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `pnpm --filter @familysync/api exec vitest run` (unit tests; skip integration DB tests)
|
||||||
|
- **Per wave merge:** `pnpm test` (full API suite) + playwright-cli smoke on permission prompt
|
||||||
|
- **Phase gate:** Full suite green + human verify on iOS device (push delivery, Home Screen required)
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `tests/broker/reminderScheduler.test.ts` — NOTIF-01 unit tests
|
||||||
|
- [ ] `tests/lib/pushCoalescer.test.ts` — NOTIF-02 coalescing unit tests
|
||||||
|
- [ ] `tests/lib/pushDispatcher.test.ts` — 410/404 pruning unit tests
|
||||||
|
- [ ] `tests/lib/eventChangeDispatcher.test.ts` — NOTIF-03 dispatch unit tests
|
||||||
|
- [ ] `tests/routes/push.test.ts` — subscription POST/DELETE integration tests
|
||||||
|
- [ ] `test/setup.ts` update — add `push_subscriptions` to afterEach truncation
|
||||||
|
- [ ] `apps/pwa/src/sw.ts` — custom SW source file (required for injectManifest build)
|
||||||
|
- [ ] Install: `pnpm --filter @familysync/api add web-push && pnpm --filter @familysync/api add -D @types/web-push`
|
||||||
|
- [ ] Install: `pnpm --filter @familysync/pwa add -D workbox-precaching workbox-core`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories (Level 1)
|
||||||
|
|
||||||
|
| ASVS Category | Applies | Standard Control |
|
||||||
|
|---------------|---------|-----------------|
|
||||||
|
| V2 Authentication | yes | Push routes behind oidcAuthMiddleware; subscription belongs to authenticated user |
|
||||||
|
| V3 Session Management | no | Push subscription is not session state |
|
||||||
|
| V4 Access Control | yes | Subscription POST/DELETE scoped to c.get('user').id; push fan-out must not cross user boundaries |
|
||||||
|
| V5 Input Validation | yes | zod validation on subscription body (endpoint string, p256dh, auth) |
|
||||||
|
| V6 Cryptography | yes | web-push handles VAPID signing; NEVER hand-roll; VAPID_PRIVATE_KEY in env only |
|
||||||
|
|
||||||
|
### Known Threat Patterns for this Stack
|
||||||
|
|
||||||
|
| Pattern | STRIDE | Standard Mitigation |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| VAPID private key exposure | Information Disclosure | Store in .env; never in code; never in git |
|
||||||
|
| Unauthorized push subscription (user A subscribes on behalf of user B) | Spoofing | Subscription POST always uses authenticated userId from OIDC session |
|
||||||
|
| Push to wrong user's subscriptions | Tampering | fan-out queries filter by userId and list access (same as SSE scope check) |
|
||||||
|
| Endpoint enumeration via POST /api/push/subscription | Information Disclosure | Endpoint is user-specific; server does not expose other users' endpoints |
|
||||||
|
| Malformed subscription body causing crypto crash | Denial of Service | zod validation before DB insert; web-push errors caught per-subscription |
|
||||||
|
| VAPID key in Docker image layers | Information Disclosure | Pass VAPID keys as environment variables at runtime (Docker Compose .env or secrets) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Constraints (from CLAUDE.md)
|
||||||
|
|
||||||
|
- **MariaDB only** (no PostgreSQL). Drizzle ORM with mysql2 driver. All migrations via `db:generate` + `db:migrate`.
|
||||||
|
- **`db:push` is UNSAFE** on populated MariaDB — never use it after data exists.
|
||||||
|
- **Single Node process** — in-memory EventEmitter fan-out is correct; no Redis.
|
||||||
|
- **Broker-only CalDAV I/O (D-13)** — notification code must not call tsdav.
|
||||||
|
- **React PWA only** — no native app. Service worker runs in browser.
|
||||||
|
- **iOS 16.4 minimum** — push requires Home Screen install; pushManager.subscribe() must be in a user gesture.
|
||||||
|
- **Every push must show a visible notification** — no silent pushes (iOS revokes after ~3).
|
||||||
|
- **web-push 3.6.7** is the designated VAPID library (CLAUDE.md stack table).
|
||||||
|
- **node-cron already installed** in apps/api — no new scheduler library needed.
|
||||||
|
- **playwright-cli** is available at `/usr/local/bin/playwright-cli` for browser-side verification.
|
||||||
|
- **Hono** is the API framework — push routes follow same pattern as existing routers.
|
||||||
|
- **`apps/api/src/db/schema.ts`** is the single source of truth for DB schema. New table goes here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (MEDIUM confidence — Context7 from official docs)
|
||||||
|
- `/web-push-libs/web-push` — VAPID key generation, sendNotification API, error codes (410/404), TypeScript usage
|
||||||
|
- `/vite-pwa/vite-plugin-pwa` — injectManifest strategy, autoUpdate with custom SW, self.__WB_MANIFEST
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence — web search + official blog)
|
||||||
|
- [https://webkit.org/blog/16535/meet-declarative-web-push/](https://webkit.org/blog/16535/meet-declarative-web-push/) — Declarative Web Push payload format, iOS 18.4+ availability
|
||||||
|
- [https://webkit.org/blog/16574/webkit-features-in-safari-18-4/](https://webkit.org/blog/16574/webkit-features-in-safari-18-4/) — Safari 18.4 feature confirmation
|
||||||
|
|
||||||
|
### Codebase (HIGH confidence — direct inspection)
|
||||||
|
- `apps/api/src/db/schema.ts` — confirmed column list, customType pattern, existing table structure
|
||||||
|
- `apps/api/src/lib/listEmitter.ts` — confirmed publishListEvent signature and call pattern
|
||||||
|
- `apps/api/src/broker/poller.ts` + `outboxWorker.ts` — confirmed event change detection hooks
|
||||||
|
- `apps/pwa/vite.config.ts` — confirmed generateSW mode, denylist, runtimeCaching
|
||||||
|
- `apps/pwa/src/components/InstallPrompt.tsx` — confirmed isInstalled(), WalkthroughSheet pattern
|
||||||
|
- `apps/api/package.json` / `apps/pwa/package.json` — confirmed web-push and workbox NOT installed
|
||||||
|
- `apps/api/drizzle.config.ts` + migration journal — confirmed db:generate+migrate workflow
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack (web-push, workbox): MEDIUM — confirmed via npm registry + Context7 from GitHub README
|
||||||
|
- Architecture: HIGH — based on direct codebase inspection; patterns derived from existing workers
|
||||||
|
- Pitfalls: HIGH (iOS) — confirmed in CLAUDE.md and STATE.md; HIGH (DB migration) — confirmed in memory note; MEDIUM (others) — based on library docs
|
||||||
|
- Service worker migration: MEDIUM — Context7 docs; runtime behavior on iOS needs human verification
|
||||||
|
|
||||||
|
**Research date:** 2026-06-09
|
||||||
|
**Valid until:** 2026-07-09 (30 days — libraries are stable)
|
||||||
Reference in New Issue
Block a user