---
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.