Files
familysync/.planning/phases/05-web-push-notifications/05-06-PLAN.md
T

7.7 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-web-push-notifications 06 tdd 4
05-02
05-04
apps/api/src/broker/reminderScheduler.ts
apps/api/src/index.ts
apps/api/tests/broker/reminderScheduler.test.ts
true
NOTIF-01
truths artifacts key_links
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
path provides exports min_lines
apps/api/src/broker/reminderScheduler.ts startReminderScheduler() + runReminderCheck() — node-cron 1-min shared-timed-event scan + dispatch
startReminderScheduler
runReminderCheck
40
from to via pattern
apps/api/src/broker/reminderScheduler.ts apps/api/src/lib/pushDispatcher.ts dispatchPush per subscription for each due shared timed event dispatchPush
from to via pattern
apps/api/src/index.ts startReminderScheduler isMainModule startup guard 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.

<threat_model>

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
</threat_model>
- RED precedes GREEN; reminderScheduler.test.ts green. - index.ts wires startReminderScheduler in the isMainModule guard. - `pnpm --filter @familysync/api typecheck` passes.

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/05-web-push-notifications/05-06-SUMMARY.md` with RED/GREEN commits.