Phase 11: Per-Event Reminders (CAL-13/14, NOTIF-04/05/06) #19

Merged
luckberg merged 41 commits from gsd/phase-11-per-event-reminders into main 2026-06-14 14:06:45 -04:00
Showing only changes of commit 72773a35eb - Show all commits
@@ -0,0 +1,161 @@
---
phase: 11-per-event-reminders
plan: "03"
subsystem: api/broker
tags: [valarm, reminderLeadMinutes, tdd, schema, outbox, sync, expand, cal-13, cal-14]
dependency_graph:
requires:
- "Plan 11-01 (buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, NewEventParams extensions)"
provides:
- "reminderLeadMinutes in eventFieldsSchema (ingress validation)"
- "reminderLeadMinutes in outboxPayloadSchema (drain re-validation)"
- "hasExplicitReminder preserve-on-edit path in outboxWorker UPDATE branch"
- "VALARM wiring in buildVeventString calls (both UPDATE and CREATE branches)"
- "reminderLeadMinutesValue derivation + upsert in sync.ts"
- "reminderLeadMinutes on CalendarOccurrence (expand.ts)"
- "reminderLeadMinutes in GET /api/events select"
affects:
- "apps/api/src/routes/events.ts"
- "apps/api/src/broker/outboxWorker.ts"
- "apps/api/src/broker/sync.ts"
- "apps/api/src/broker/expand.ts"
- "Plan 11-02 (reminderScheduler — scheduler reads reminderLeadMinutes from DB)"
tech_stack:
added: []
patterns:
- "hasExplicitReminder sentinel mirrors hasExplicitRecurrence WR-01 pattern"
- "Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes') for absent-vs-null distinction (D-08)"
- "classifyValarms(rawVevent) for series-level reminderLeadMinutes derivation in expand.ts"
- "computeAlertInstantUtc(start, leadDays, tz) for all-day absolute DATE-TIME trigger"
- "extractValarms(rawVevent) preserve-on-edit re-attachment via addSubcomponent"
key_files:
created: []
modified:
- apps/api/src/routes/events.ts
- apps/api/src/broker/outboxWorker.ts
- apps/api/src/broker/sync.ts
- apps/api/src/broker/expand.ts
- apps/api/tests/broker/outboxWorker.test.ts
- apps/api/tests/broker/sync.test.ts
- apps/api/tests/broker/expand.test.ts
decisions:
- "D-REMIND-ABSENT: absent field (not in payload) = no-change path (D-08); Object.prototype.hasOwnProperty.call distinguishes absent from null — mirrors WR-01 for VALARM preservation"
- "D-REMIND-EXPAND: reminderLeadMinutes derived inside expandOccurrences via classifyValarms(rawVevent) — self-contained; consistent with sync.ts derivation (both consume the same VEVENT source)"
- "D-REMIND-ALLDAY-LEADDAYS: all-day leadDays = reminderLeadMinutes / 1440 (consistent with D-05 mapping); computeAlertInstantUtc called at drain time (not enqueue) for correct DST"
metrics:
duration_minutes: 8
completed_date: "2026-06-14"
tasks_completed: 3
files_modified: 7
---
# Phase 11 Plan 03: reminderLeadMinutes End-to-End Plumbing Summary
`reminderLeadMinutes` round-trips end-to-end: `eventFieldsSchema` ingress validation → outbox payload drain → `buildVeventString` VALARM emission → Fastmail PUT; sync.ts parses native VALARMs into the DB column (scheduler ground truth); `CalendarOccurrence` surfaces the value for edit-mode picker pre-population.
## Tasks Completed
| Task | Description | RED Commit | GREEN Commit |
|------|-------------|------------|--------------|
| 1 | Schema field + outbox worker preserve-on-edit + buildVeventString wiring | 79f6871 | 4f42b75 |
| 2 | sync.ts VALARM → reminderLeadMinutes upsert | cdca930 | 1cc0d72 |
| 3 | Surface reminderLeadMinutes on CalendarOccurrence + GET select | 7df11d2 | e171431 |
## Schema Field (eventFieldsSchema + outboxPayloadSchema)
Both schemas now have:
```typescript
reminderLeadMinutes: z.number().int().min(0).nullable().optional()
```
Four-state semantics (D-08):
- `absent` — field not present in payload; UPDATE branch preserves existing VALARM verbatim (D-08)
- `null` — explicit "None" → VALARM cleared on write-back
- `0` — same-day all-day (9 AM on event date); timed 0 = None (D-06)
- `positive` — N minutes before event start (timed) or N/1440 days before (all-day)
## hasExplicitReminder Preserve-on-Edit Path (CAL-14)
Pattern mirrors the WR-01 `hasExplicitRecurrence` + `_preservedRrule` preserve path:
UPDATE branch computes:
- `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')`
- `!hasExplicitReminder` + rawVevent has VALARMs → `valarmsToPreserve = extractValarms(rawVevent)` (preserve verbatim via addSubcomponent)
- `hasExplicitReminder` + allDay + value → `allDayAlertInstantUtcUpdate = computeAlertInstantUtc(start, lead/1440, tz)`
- `hasExplicitReminder` + null → clear (no valarms, reminderLeadMinutes=null passed to buildVeventString)
buildVeventString call extended with: `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc: allDayAlertInstantUtcUpdate`.
CREATE branch: no preserve path (new event always carries explicit picker value). Computes `allDayAlertInstantUtcCreate` from `reminderLeadMinutes / 1440` when allDay.
## sync.ts Derivation Rule
```typescript
import { classifyValarms } from './vevent.js';
const alarmClass = classifyValarms(obj.data as string);
const reminderLeadMinutesValue: number | null =
alarmClass.kind === 'preset' || alarmClass.kind === 'offlist'
? alarmClass.leadMinutes
: null;
```
Written to both `.values({...})` and `.onDuplicateKeyUpdate({ set: {...} })`. Classification rules:
- `preset` or `offlist``leadMinutes` (scheduler ground truth)
- `custom` (absolute DATE-TIME or multiple VALARMs) → `null` (D-07/NOTIF-05)
- `none``null` (no VALARM)
No schema DDL change — `reminder_lead_minutes` column was added by Phase 10 migration.
## CalendarOccurrence Propagation (D-10)
```typescript
// CalendarOccurrence interface:
reminderLeadMinutes: number | null; // after hasRrule
```
Derived once per VEVENT in `expandOccurrences` via `classifyValarms(rawVevent)` (series-level, D-10). All occurrences inherit the master's value. Added to both non-recurring and recurring occurrence construction branches. GET `/api/events` select also includes `calendarEvents.reminderLeadMinutes` for edit-mode pre-population.
## Verification Results
- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/broker/vevent.test.ts`: **132/132 PASS**
- `pnpm --filter @familysync/api exec tsc --noEmit`: **CLEAN (0 errors)**
- No `drizzle-kit push` introduced; no schema.ts DDL change
- 13 new tests added: 4 (Task 1 outboxWorker), 5 (Task 2 sync), 4 (Task 3 expand)
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None. All wiring is complete end-to-end. No placeholder values or TODO markers.
## Threat Flags
No new threat surface. All new fields are bounded integers validated by Zod at both ingress (eventFieldsSchema) and drain (outboxPayloadSchema) — T-11-06 mitigated. `classifyValarms` is try/catch safe — T-11-07 mitigated. The VALARM preserve path (`extractValarms`) rides the existing CR-02 scoped query on the writing member's calendar — T-11-08 unchanged.
## Self-Check: PASSED
Files exist:
- FOUND: apps/api/src/routes/events.ts
- FOUND: apps/api/src/broker/outboxWorker.ts
- FOUND: apps/api/src/broker/sync.ts
- FOUND: apps/api/src/broker/expand.ts
- FOUND: apps/api/tests/broker/outboxWorker.test.ts
- FOUND: apps/api/tests/broker/sync.test.ts
- FOUND: apps/api/tests/broker/expand.test.ts
Commits exist:
- 79f6871: test(11-03) RED Task 1
- 4f42b75: feat(11-03) GREEN Task 1
- cdca930: test(11-03) RED Task 2
- 1cc0d72: feat(11-03) GREEN Task 2
- 7df11d2: test(11-03) RED Task 3
- e171431: feat(11-03) GREEN Task 3
Key exports verified:
- reminderLeadMinutes in eventFieldsSchema: CONFIRMED (grep: `reminderLeadMinutes: z.number()`)
- hasExplicitReminder in outboxWorker.ts: CONFIRMED
- reminderLeadMinutes on CalendarOccurrence: CONFIRMED
- classifyValarms import in sync.ts: CONFIRMED
- classifyValarms import in expand.ts: CONFIRMED