docs(11-03): complete Plan 03 — reminderLeadMinutes end-to-end plumbing
- Schema field + VALARM wiring in outbox worker (CAL-13/CAL-14) - sync.ts VALARM classification → reminderLeadMinutes upsert (D-07/NOTIF-05) - CalendarOccurrence.reminderLeadMinutes + GET select (D-10) - 13 new TDD tests; 132/132 broker tests pass; tsc clean
This commit is contained in:
@@ -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
|
||||||
Reference in New Issue
Block a user