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