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

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

<context>
@.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
</context>

<feature>
  <name>reminderScheduler — shared-timed-event 15-min reminder scan</name>
  <files>apps/api/src/broker/reminderScheduler.ts, apps/api/tests/broker/reminderScheduler.test.ts</files>
  <read_first>
    - 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}")
  </read_first>
  <behavior>
    - 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).
  </behavior>
  <implementation>
    Module-level `const sentReminders = new Set<string>()` (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`).
  </implementation>
  <verify>
    <automated>cd apps/api && pnpm exec vitest run tests/broker/reminderScheduler.test.ts && grep -q "startReminderScheduler" src/index.ts</automated>
  </verify>
  <acceptance_criteria>
    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.
  </acceptance_criteria>
</feature>

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

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

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

<output>
Create `.planning/phases/05-web-push-notifications/05-06-SUMMARY.md` with RED/GREEN commits.
</output>
