--- phase: 05-web-push-notifications plan: 06 type: tdd wave: 4 depends_on: [05-02, 05-04] files_modified: - apps/api/src/broker/reminderScheduler.ts - apps/api/src/index.ts - apps/api/tests/broker/reminderScheduler.test.ts autonomous: true requirements: [NOTIF-01] must_haves: truths: - "Every minute the scheduler scans for SHARED (isShared=true) TIMED (allDay=false) events whose dtstartUtc is in [now+14min, now+16min] and dispatches a reminder to ALL members' subscriptions (NOTIF-01, D-05/D-06)" - "All-day events get no reminder (D-07); non-shared events get no reminder (D-05)" - "The same (eventUid, minuteBucket) never fires twice — in-memory dedup Set prevents the window-boundary double-fire (RESEARCH Pitfall 5 / Open Question 3)" - "Reminder copy uses the event title: title '{EventTitle}', body 'Starts in 15 min' (D-02, depends on calendar_events.title)" - "An empty shared-calendar set (Family calendar not yet created per D-16) produces zero sends and no crash" artifacts: - path: "apps/api/src/broker/reminderScheduler.ts" provides: "startReminderScheduler() + runReminderCheck() — node-cron 1-min shared-timed-event scan + dispatch" exports: ["startReminderScheduler", "runReminderCheck"] min_lines: 40 key_links: - from: "apps/api/src/broker/reminderScheduler.ts" to: "apps/api/src/lib/pushDispatcher.ts" via: "dispatchPush per subscription for each due shared timed event" pattern: "dispatchPush" - from: "apps/api/src/index.ts" to: "startReminderScheduler" via: "isMainModule startup guard" pattern: "startReminderScheduler" --- TDD NOTIF-01: a node-cron scheduler fires once per minute, finds shared Family-calendar timed events starting in ~15 minutes, and pushes a reminder to all members. Reminders are SHARED-calendar-only by design (D-05) — native device calendars cover personal events; FamilySync owns the cross-ecosystem shared coordination gap. Purpose: This is the reminder vertical slice. The shared+timed+window filter (enforced in the QUERY, not the copy — D-05 is the most consequential locked decision) and the dedup Set are the load-bearing correctness guarantees. The path must no-op gracefully when no shared calendar exists yet (D-16 deferral). Output: reminderScheduler.ts (startReminderScheduler + runReminderCheck) wired into index.ts's isMainModule guard, turning the Plan 05-01 RED scaffold GREEN. @.planning/PROJECT.md @.planning/ROADMAP.md @apps/api/src/broker/poller.ts @apps/api/src/index.ts @apps/api/src/db/schema.ts @apps/api/src/lib/pushDispatcher.ts @.planning/phases/05-web-push-notifications/05-RESEARCH.md @.planning/phases/05-web-push-notifications/05-UI-SPEC.md reminderScheduler — shared-timed-event 15-min reminder scan apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts - apps/api/src/broker/poller.ts (startBrokerPoller cron shape lines 83-89; per-item try/catch lines 69-76) - apps/api/src/index.ts (isMainModule guard lines 107-117 — where startReminderScheduler + setVapidDetails are wired alongside startBrokerPoller/startOutboxWorker) - apps/api/src/db/schema.ts (calendars.isShared, calendarEvents.allDay/dtstartUtc/uid/title, pushSubscriptions) - apps/api/src/lib/pushDispatcher.ts (dispatchPush + buildPushBody) - .planning/phases/05-web-push-notifications/05-RESEARCH.md (Pattern 5 scheduler; Pitfall 5 dedup; Pitfall 6 title column; ### Reminder dedup in-memory Set) - .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Event reminder copy: title "{EventTitle}", body "Starts in 15 min", tag "reminder-{eventUid}", data.url "/calendar?date={YYYY-MM-DD}&event={eventUid}") - runReminderCheck(now=new Date()): SELECT calendarEvents JOIN calendars WHERE calendars.isShared=true AND calendarEvents.allDay=false AND dtstartUtc BETWEEN now+14min AND now+16min. For each due event not already in the dedup Set (key `${uid}:${minuteBucket}` where minuteBucket = floor(now ms / 60000)): load ALL push_subscriptions (shared event → notify every member), dispatchPush a reminder payload built via buildPushBody({ title: event.title ?? event.uid, body:'Starts in 15 min', tag:`reminder-${uid}`, navigate:`/calendar?date=${yyyyMmDd(dtstartUtc)}&event=${uid}` }), then add the key to the Set. - Cases (vi.useFakeTimers, real DB harness, dispatchPush mocked): - shared timed event at now+15m → dispatched to both members' subscriptions. - all-day event at now+15m → NOT dispatched (D-07). - non-shared (isShared=false) timed event at now+15m → NOT dispatched (D-05). - same event, two consecutive minute ticks both inside the window → dispatched ONCE (dedup). - no shared calendars / no due events → zero dispatchPush calls, no throw (D-16 empty case). - event.title null → falls back to uid in the title (still sends). Module-level `const sentReminders = new Set()` (single-process dedup per D-12; lost on restart — acceptable for a two-person household). startReminderScheduler() wraps runReminderCheck in schedule('* * * * *', …).catch(...) exactly like startBrokerPoller. Per-event and per-subscription try/catch with '[broker/reminderScheduler]' prefix (poller idiom) so one bad event/subscription never aborts the cycle. yyyyMmDd derives the calendar date from dtstartUtc in UTC for the deep-link. In index.ts add startReminderScheduler() inside the existing isMainModule() guard, after startOutboxWorker() and after the setVapidDetails call (Plan 05-04 added setVapidDetails; if 05-04 and 05-06 land in the same drain, ensure setVapidDetails precedes the scheduler). Export runReminderCheck for the test (inject `now`). cd apps/api && pnpm exec vitest run tests/broker/reminderScheduler.test.ts && grep -q "startReminderScheduler" src/index.ts Test green: shared+timed in window → dispatched to all members; all-day excluded; non-shared excluded; dedup single-fire; empty set no-op; title fallback. index.ts starts the scheduler in the isMainModule guard. ## Trust Boundaries | Boundary | Description | |----------|-------------| | reminder query → push audience | reminder eligibility is decided by the SQL WHERE, not by any request | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-05-17 | Information Disclosure | reminder leaking a personal-calendar event | mitigate | D-05 enforced in the QUERY: WHERE calendars.isShared = true — personal events are never selected, not merely hidden in copy | | T-05-18 | Denial of Service | duplicate reminder storm at window boundary | mitigate | in-memory dedup Set keyed (uid, minuteBucket); per-event try/catch isolates failures | | T-05-19 | Denial of Service | one bad subscription aborting the cycle | mitigate | per-subscription try/catch; dispatchPush already swallows + prunes 410/404 | - RED precedes GREEN; reminderScheduler.test.ts green. - index.ts wires startReminderScheduler in the isMainModule guard. - `pnpm --filter @familysync/api typecheck` passes. - Failing test committed (RED). - runReminderCheck + startReminderScheduler implemented; test passes (GREEN). - D-05 shared-only (query-enforced), D-07 all-day-excluded, dedup, empty-set no-op, title fallback all verified. Create `.planning/phases/05-web-push-notifications/05-06-SUMMARY.md` with RED/GREEN commits.