docs(05-06): complete reminderScheduler plan
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
---
|
||||
phase: 05-web-push-notifications
|
||||
plan: 06
|
||||
subsystem: api/reminder-scheduler
|
||||
tags: [web-push, reminder, node-cron, tdd, red-green, notif-01, shared-calendar]
|
||||
dependency_graph:
|
||||
requires: [05-01, 05-02, 05-04]
|
||||
provides: [startReminderScheduler, runReminderCheck, shared-event 15-min reminder dispatch]
|
||||
affects:
|
||||
- apps/api/src/broker/reminderScheduler.ts
|
||||
- apps/api/src/index.ts
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- node-cron 1-min schedule (same shape as startBrokerPoller in poller.ts)
|
||||
- Drizzle cross-join (sql`1=1`) to fan shared events out to all push subscribers
|
||||
- in-memory dedup Set keyed uid:minuteBucket (D-12 single-process, no Redis)
|
||||
- per-event + per-subscription try/catch error isolation (T-05-18, T-05-19)
|
||||
key_files:
|
||||
created:
|
||||
- apps/api/src/broker/reminderScheduler.ts
|
||||
modified:
|
||||
- apps/api/src/index.ts
|
||||
decisions:
|
||||
- "Cross-join (sql`1=1`) used to pair each due shared event with ALL push subscriptions in one Drizzle query (2 innerJoins: calendarEvents→calendars→pushSubscriptions) — matches the test scaffold's mock chain shape"
|
||||
- "Grouping by uid after the flat cross-join result ensures all subscriptions for a deduped event are dispatched in one pass (prevents sub2 being silently skipped after dedup fires for sub1)"
|
||||
- "sentReminders.add(key) called BEFORE iterating subs to prevent re-entry on concurrent ticks"
|
||||
- "title fallback: event.title ?? uid — prevents 'undefined' in push copy for pre-05-07 rows"
|
||||
metrics:
|
||||
duration: 6
|
||||
completed_date: "2026-06-10"
|
||||
tasks_completed: 1
|
||||
files_changed: 2
|
||||
---
|
||||
|
||||
# Phase 05 Plan 06: reminderScheduler — shared timed 15-min reminder scan — Summary
|
||||
|
||||
TDD GREEN: `reminderScheduler.ts` implemented with D-05/D-07 SQL-enforced filtering, in-memory dedup, fan-out cross-join, and per-event error isolation — all 3 RED scaffold tests pass. Scheduler wired into `index.ts` `isMainModule()` guard.
|
||||
|
||||
## Tasks Executed
|
||||
|
||||
### Task 1: Implement reminderScheduler.ts (GREEN)
|
||||
|
||||
**Status:** Completed. Commit: `b95f671`
|
||||
|
||||
The RED scaffold (`tests/broker/reminderScheduler.test.ts`) was already committed in Plan 05-01 at `ef558b6`. This plan turns it GREEN.
|
||||
|
||||
Created `apps/api/src/broker/reminderScheduler.ts` with:
|
||||
|
||||
**`runReminderCheck(now = new Date())`** — single reminder scan cycle:
|
||||
- Drizzle query: `db.select().from(calendarEvents).innerJoin(calendars, ...).innerJoin(pushSubscriptions, sql\`1=1\`)` — 2 innerJoins; cross-join fans each event out to all subscribers
|
||||
- WHERE: `isShared=true AND allDay=false AND dtstartUtc >= now+14min AND dtstartUtc <= now+16min`
|
||||
- D-05 enforced in QUERY (not copy) — personal events excluded at SQL level
|
||||
- D-07 enforced in QUERY — all-day events excluded at SQL level
|
||||
- Groups flat rows by uid, collects per-event subscription list
|
||||
- Dedup: `sentReminders.add(\`${uid}:${minuteBucket}\`)` prevents window-boundary double-fire (T-05-18)
|
||||
- Title fallback: `event.title ?? uid` — no "undefined" in reminder copy (NOTIF-01 / plan note)
|
||||
- Notification payload: `{ title, body: 'Starts in 15 min', tag: \`reminder-${uid}\`, navigate: \`/calendar?date=${yyyyMmDd(dtstartUtc)}&event=${uid}\` }`
|
||||
- Per-event and per-subscription try/catch for error isolation (T-05-18, T-05-19)
|
||||
- Empty shared-calendar / empty push_subscriptions: cross-join returns 0 rows → zero sends, no crash (D-16)
|
||||
|
||||
**`startReminderScheduler()`** — node-cron `* * * * *` schedule (every minute):
|
||||
- Same shape as `startBrokerPoller` in `poller.ts` — `.catch()` on the returned promise
|
||||
- Not called at import time (guards the test process per WR-04)
|
||||
|
||||
**`index.ts`** — added `startReminderScheduler()` call in the `isMainModule()` guard, after `startOutboxWorker()` and after `webpush.setVapidDetails()` (so VAPID is configured before the scheduler starts).
|
||||
|
||||
**TDD Gate Compliance:**
|
||||
- RED: `test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation` — `ef558b6` (Plan 05-01)
|
||||
- GREEN: `feat(05-06): implement reminderScheduler — shared timed 15-min reminder scan` — `b95f671`
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
|
||||
|
||||
Test Files 1 passed (1)
|
||||
Tests 3 passed (3)
|
||||
|
||||
grep -q "startReminderScheduler" apps/api/src/index.ts → PASSED
|
||||
pnpm --filter @familysync/api typecheck → passed (no errors)
|
||||
```
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed issues
|
||||
|
||||
None. Plan executed exactly as written.
|
||||
|
||||
### Architecture note (no deviation — design decision)
|
||||
|
||||
The cross-join approach (`innerJoin(pushSubscriptions, sql\`1=1\`)`) was chosen over two separate `db.select()` calls because:
|
||||
1. The test scaffold's mock requires exactly 2 `innerJoin()` calls in a single chain (`.from().innerJoin().innerJoin().where()`)
|
||||
2. A cross-join is semantically correct: shared reminder → all members
|
||||
3. Grouping by uid after the flat result correctly handles the fan-out while maintaining the `uid:minuteBucket` dedup semantics
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None. The scheduler is fully implemented. The `calendar_events.title` column may be NULL for events synced before Plan 05-07 (which adds title extraction to the sync path), but the null fallback (`event.title ?? uid`) handles this gracefully without stubbing.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
No new threat surface. All three plan threats mitigated:
|
||||
|
||||
| Threat | Status |
|
||||
|--------|--------|
|
||||
| T-05-17: personal-calendar event in reminder | Mitigated — `WHERE isShared=true` enforced in SQL |
|
||||
| T-05-18: duplicate reminder storm at window boundary | Mitigated — in-memory dedup Set; per-event try/catch |
|
||||
| T-05-19: one bad subscription aborting cycle | Mitigated — per-subscription try/catch; dispatchPush swallows 410/404 |
|
||||
|
||||
## Self-Check
|
||||
|
||||
**Files created/verified:**
|
||||
- [x] apps/api/src/broker/reminderScheduler.ts — exists
|
||||
|
||||
**Commits verified:**
|
||||
- ef558b6: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncation (RED gate — Plan 05-01)
|
||||
- b95f671: feat(05-06): implement reminderScheduler — shared timed 15-min reminder scan (GREEN gate)
|
||||
|
||||
## Self-Check: PASSED
|
||||
Reference in New Issue
Block a user