chore: archive v1.1 phase directories to milestones/v1.1-phases/
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
a2890d1542
commit
c7955a46b9
@@ -0,0 +1,198 @@
|
||||
---
|
||||
phase: 11-per-event-reminders
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: [11-01]
|
||||
files_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
|
||||
autonomous: true
|
||||
requirements: [CAL-13, CAL-14]
|
||||
must_haves:
|
||||
truths:
|
||||
- "reminderLeadMinutes round-trips end to end through eventFieldsSchema → outbox payload → buildVeventString → Fastmail PUT"
|
||||
- "Editing an event with no picker change preserves the existing VALARM verbatim (never stripped)"
|
||||
- "Explicit null reminderLeadMinutes clears the VALARM; explicit value replaces it"
|
||||
- "An all-day event written with a day-lead gets an absolute DATE-TIME VALARM computed at 9 AM local (D-04) from the D-05 minute mapping"
|
||||
- "Events synced from Fastmail with a native VALARM have reminderLeadMinutes populated in the DB (scheduler ground truth)"
|
||||
- "GET /api/events occurrences carry reminderLeadMinutes for edit-mode pre-population (NULL-vs-0 distinct)"
|
||||
artifacts:
|
||||
- path: "apps/api/src/routes/events.ts"
|
||||
provides: "reminderLeadMinutes in eventFieldsSchema + GET occurrence select"
|
||||
contains: "reminderLeadMinutes"
|
||||
- path: "apps/api/src/broker/outboxWorker.ts"
|
||||
provides: "outboxPayloadSchema field + hasExplicitReminder preserve path + buildVeventString wiring (both branches)"
|
||||
contains: "hasExplicitReminder"
|
||||
- path: "apps/api/src/broker/sync.ts"
|
||||
provides: "VALARM parse + reminderLeadMinutes upsert"
|
||||
contains: "reminderLeadMinutes"
|
||||
- path: "apps/api/src/broker/expand.ts"
|
||||
provides: "reminderLeadMinutes on CalendarOccurrence"
|
||||
contains: "reminderLeadMinutes"
|
||||
key_links:
|
||||
- from: "outboxWorker update branch"
|
||||
to: "extractValarms(rawVevent) / computeAlertInstantUtc"
|
||||
via: "hasExplicitReminder gate"
|
||||
pattern: "hasExplicitReminder"
|
||||
- from: "sync.ts upsert"
|
||||
to: "classifyValarms(rawVevent)"
|
||||
via: "reminderLeadMinutesValue derivation"
|
||||
pattern: "classifyValarms"
|
||||
- from: "GET /api/events select"
|
||||
to: "expandOccurrences → CalendarOccurrence.reminderLeadMinutes"
|
||||
via: "DB column surfaced through expansion"
|
||||
pattern: "reminderLeadMinutes"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Plumb `reminderLeadMinutes` end to end through the backend: extend `eventFieldsSchema` (route ingress) and `outboxPayloadSchema` (outbox drain) with `z.number().int().min(0).nullable().optional()`; add the `hasExplicitReminder` preserve-on-no-change path in the outbox update AND create branches (mirroring `hasExplicitRecurrence`); wire `valarms` / `reminderLeadMinutes` / `allDayAlertInstantUtc` into the `buildVeventString` calls; write `reminderLeadMinutes` into the DB on sync (so the scheduler has ground truth for native-client VALARMs); and surface `reminderLeadMinutes` on `CalendarOccurrence` + the GET select for edit-mode pre-population.
|
||||
|
||||
Purpose: Carry the NULL-vs-0-vs-absent distinction (D-06) all the way through (CAL-13), and preserve existing VALARMs verbatim when the user does not touch the picker (CAL-14, D-08). Consumes the builders/classifier/extractor/computeAlertInstantUtc from Plan 11-01.
|
||||
Output: Extended schemas + worker branches + sync upsert + occurrence interface; extended outboxWorker.test.ts.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/gsd-core/workflows/execute-plan.md
|
||||
@$HOME/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/11-per-event-reminders/11-RESEARCH.md
|
||||
@.planning/phases/11-per-event-reminders/11-PATTERNS.md
|
||||
@.planning/phases/11-per-event-reminders/11-VALIDATION.md
|
||||
@.planning/phases/11-per-event-reminders/11-01-SUMMARY.md
|
||||
@apps/api/src/broker/vevent.ts
|
||||
</context>
|
||||
|
||||
<artifacts_this_phase_produces>
|
||||
This plan creates (exclude from drift checks — NEW):
|
||||
- `reminderLeadMinutes` field on `eventFieldsSchema` (events.ts)
|
||||
- `reminderLeadMinutes` field on `outboxPayloadSchema` (outboxWorker.ts)
|
||||
- `hasExplicitReminder` const + `valarmsToPreserve` / `allDayAlertInstantUtc` locals in both outbox branches (outboxWorker.ts)
|
||||
- `reminderLeadMinutesValue` derivation + upsert write (sync.ts)
|
||||
- `reminderLeadMinutes` field on `CalendarOccurrence` (expand.ts) + in the GET select / expandOccurrences propagation (events.ts)
|
||||
CONSUMES from Plan 11-01 (NOT new here): buildTimedValarm, buildAllDayValarm, classifyValarms, extractValarms, computeAlertInstantUtc, extended NewEventParams.
|
||||
</artifacts_this_phase_produces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="execute" tdd="true">
|
||||
<name>Task 1: Schema field + outbox worker preserve-on-edit + buildVeventString wiring</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/src/broker/outboxWorker.ts, apps/api/tests/broker/outboxWorker.test.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/routes/events.ts lines 101-120 (eventFieldsSchema — add field after recurrenceCount)
|
||||
- apps/api/src/broker/outboxWorker.ts lines 73-95 (outboxPayloadSchema), 425-466 (update branch hasExplicitRecurrence + freshEtagRows rawVevent read + preservedRrule), 483-492 (update buildVeventString call), 543-560 (create branch hasExplicitRecurrence)
|
||||
- apps/api/tests/broker/outboxWorker.test.ts lines 246-375 (ICS-building + CR-01 _preservedRrule preserve tests to mirror)
|
||||
- 11-RESEARCH.md Q2 (NULL-vs-0 zod semantics, hasExplicitReminder sentinel, NewEventParams extension, worker path resolution) ; 11-PATTERNS.md § outboxWorker.ts (exact insertion points + the buildVeventString call extension)
|
||||
- apps/api/src/broker/vevent.ts (Plan 11-01: extractValarms, computeAlertInstantUtc, NewEventParams valarms/reminderLeadMinutes/allDayAlertInstantUtc)
|
||||
</read_first>
|
||||
<action>
|
||||
Add `reminderLeadMinutes: z.number().int().min(0).nullable().optional()` to BOTH `eventFieldsSchema` (events.ts, after recurrenceCount) and `outboxPayloadSchema` (outboxWorker.ts, after recurrenceCount) with a comment documenting the four states: absent=no-change (D-08), null=clear VALARM, 0=same-day all-day, positive=timed/day lead. Import `extractValarms` and `computeAlertInstantUtc` from `./vevent.js`.
|
||||
In the outbox UPDATE branch: add `const hasExplicitReminder = Object.prototype.hasOwnProperty.call(fields, 'reminderLeadMinutes')` adjacent to the existing hasExplicitRecurrence line. Resolve VALARM handling before the buildVeventString call: if `!hasExplicitReminder` and a freshEtagRows rawVevent exists → `valarmsToPreserve = extractValarms(freshEtagRows[0].rawVevent)`; else if `hasExplicitReminder && fields.reminderLeadMinutes != null && fields.allDay` → `allDayAlertInstantUtc = computeAlertInstantUtc(fields.start, fields.reminderLeadMinutes / 1440 /* D-05 leadDays */, process.env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone)`. Extend the update buildVeventString call with `reminderLeadMinutes: hasExplicitReminder ? fields.reminderLeadMinutes : undefined`, `valarms: valarmsToPreserve`, `allDayAlertInstantUtc`.
|
||||
In the CREATE branch: a create always carries an explicit picker value (no rawVevent source), so compute allDayAlertInstantUtc the same way when `fields.reminderLeadMinutes != null && fields.allDay`, and pass `reminderLeadMinutes: fields.reminderLeadMinutes` + `allDayAlertInstantUtc` to its buildVeventString call (no valarms — create has no source to preserve).
|
||||
Verify TS narrowing: `fields.reminderLeadMinutes` is `number | null | undefined` from the schema; the buildVeventString param accepts `number | null`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/outboxWorker.test.ts tests/broker/vevent.test.ts</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- outboxWorker.test.ts asserts: an UPDATE row with NO reminderLeadMinutes field whose stored rawVevent has a VALARM → emitted ICS still contains that VALARM (CAL-14 preserve, mirrors the CR-01 _preservedRrule test).
|
||||
- test asserts an UPDATE/CREATE row with reminderLeadMinutes=15 (timed) → emitted ICS contains `TRIGGER:-PT15M`.
|
||||
- test asserts an UPDATE/CREATE row with reminderLeadMinutes=null → emitted ICS contains no VALARM (clear).
|
||||
- test asserts an all-day CREATE row with reminderLeadMinutes=1440 → emitted ICS contains an absolute `VALUE=DATE-TIME` trigger.
|
||||
- both vitest files exit 0; `tsc --noEmit` clean.
|
||||
</acceptance_criteria>
|
||||
<done>reminderLeadMinutes validated on ingress + drain; both worker branches honor absent/null/value with VALARM preserve mirroring the RRULE-preserve pattern.</done>
|
||||
</task>
|
||||
|
||||
<task type="execute" tdd="true">
|
||||
<name>Task 2: sync.ts VALARM → reminderLeadMinutes upsert</name>
|
||||
<files>apps/api/src/broker/sync.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/broker/sync.ts lines 120-172 (field extraction block + insert/onDuplicateKeyUpdate values)
|
||||
- 11-RESEARCH.md "Pitfall: sync.ts Does Not Currently Write reminderLeadMinutes" + 11-PATTERNS.md § sync.ts (classifyValarms-based reminderLeadMinutesValue derivation + upsert additions)
|
||||
- apps/api/src/broker/vevent.ts (Plan 11-01: classifyValarms / AlarmClassification)
|
||||
</read_first>
|
||||
<action>
|
||||
Import `classifyValarms` from `./vevent.js`. After the titleValue/locationValue extraction in the per-object loop, derive `const reminderLeadMinutesValue: number | null = (alarmClass.kind === 'preset' || alarmClass.kind === 'offlist') ? alarmClass.leadMinutes : null` where `alarmClass = classifyValarms(obj.data as string)`. 'custom' (absolute/multi) and 'none' both map to null (the scheduler cannot resolve a single relative lead from those — D-07/NOTIF-05). Add `reminderLeadMinutes: reminderLeadMinutesValue` to BOTH the `.values({...})` object and the `.onDuplicateKeyUpdate({ set: {...} })` object so re-synced events keep the column current. Do NOT add or change any DB DDL — the `reminder_lead_minutes` column already exists and is nullable (no migration this phase; never drizzle-kit push).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- A sync test (extend an existing sync/broker test or add one) asserts that ingesting a VCALENDAR with a single `TRIGGER:-PT30M` VALARM writes reminderLeadMinutes=30 to calendar_events.
|
||||
- test asserts ingesting a VCALENDAR with no VALARM writes reminderLeadMinutes=null.
|
||||
- test asserts ingesting a VCALENDAR with an absolute DATE-TIME or two VALARMs writes reminderLeadMinutes=null (custom → null).
|
||||
- full broker test dir exits 0; `tsc --noEmit` clean.
|
||||
</acceptance_criteria>
|
||||
<done>sync upsert populates reminderLeadMinutes from the parsed VALARM (preset/offlist → minutes; custom/none → null), so the scheduler has ground truth for native-client alarms.</done>
|
||||
</task>
|
||||
|
||||
<task type="execute" tdd="true">
|
||||
<name>Task 3: Surface reminderLeadMinutes on CalendarOccurrence + GET select</name>
|
||||
<files>apps/api/src/broker/expand.ts, apps/api/src/routes/events.ts</files>
|
||||
<read_first>
|
||||
- apps/api/src/broker/expand.ts lines 37-69 (CalendarOccurrence interface), 169-179 (expandOccurrences signature), 255-307 (where occurrence objects are built — both non-recurring and recurring branches set hasRrule)
|
||||
- apps/api/src/routes/events.ts lines 166-230 (GET select + expandOccurrences call site)
|
||||
- 11-PATTERNS.md § expand.ts (add field after hasRrule; note OccurrenceMeta may also need it depending on propagation)
|
||||
</read_first>
|
||||
<action>
|
||||
Add `reminderLeadMinutes: number | null` to the `CalendarOccurrence` interface in expand.ts (after `hasRrule`), documenting NULL=no reminder / 0=same-day all-day / positive=lead (D-06). Add `reminderLeadMinutes` to the GET `.select({...})` in events.ts (line 166 block). Pass it from each selected row through `expandOccurrences(...)` so every emitted occurrence carries the master event's lead (series-level, D-10 — all occurrences inherit the master's reminderLeadMinutes). Set `reminderLeadMinutes` in both occurrence-construction branches in expand.ts (non-recurring and recurring), captured once like `isRecurring`. If the value flows through `OccurrenceMeta`, extend that interface too. Do NOT change the DB schema.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @familysync/api exec vitest run tests/broker/ tests/routes/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- An expand test asserts CalendarOccurrence.reminderLeadMinutes carries the master event's value (e.g. a master with reminderLeadMinutes=30 → every expanded occurrence has 30; series-level D-10).
|
||||
- test asserts a NULL-lead master yields occurrences with reminderLeadMinutes=null and a 0-lead all-day master yields 0 (NULL-vs-0 preserved through expansion).
|
||||
- `tsc --noEmit` clean for apps/api (the new field is required on CalendarOccurrence, forcing every construction site to set it).
|
||||
- test dirs exit 0.
|
||||
</acceptance_criteria>
|
||||
<done>GET /api/events occurrences expose reminderLeadMinutes (NULL-vs-0 distinct), enabling edit-mode picker pre-population by the frontend.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client → POST/PATCH /api/events | Untrusted JSON; reminderLeadMinutes crosses here, validated by eventFieldsSchema |
|
||||
| stored outbox payload → drain | Re-validated by outboxPayloadSchema (IN-03 defense-in-depth) |
|
||||
| Fastmail rawVevent → sync upsert | External iCalendar parsed by classifyValarms (try/catch safe) |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-11-06 | Tampering | reminderLeadMinutes ingress | mitigate | `z.number().int().min(0).nullable().optional()` at eventFieldsSchema AND re-validated at outboxPayloadSchema — bounded integer, no injection vector; this IS the ASVS V5 input-validation coverage |
|
||||
| T-11-07 | Tampering | sync parsing native VALARM | mitigate | classifyValarms wraps ICAL.parse in try/catch; only a bounded integer (minutes) is extracted; no string interpolated into SQL (Drizzle parameterized) |
|
||||
| T-11-08 | Spoofing/IDOR | preserve-on-edit reads rawVevent | mitigate | freshEtagRows read is already scoped to the writing member's calendar (CR-02 join on userId+calendarUrl); unchanged — VALARM extraction rides the same scoped query |
|
||||
| T-11-SC | Tampering | npm installs | accept | No new packages this phase |
|
||||
|
||||
No new security surface: the only new ingress field is a bounded integer; existing ASVS V5 input-validation coverage is maintained (the new zod field is the validation). Push body remains hardcoded/humanized (Plan 02), not user-controlled.
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- `pnpm --filter @familysync/api exec vitest run tests/broker/ tests/routes/` green.
|
||||
- `pnpm --filter @familysync/api exec tsc --noEmit` clean (the required CalendarOccurrence field forces all construction sites).
|
||||
- Grep confirms no `drizzle-kit push` introduced and no schema.ts DDL change.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- reminderLeadMinutes carried through schemas, worker (both branches), sync, and occurrences with NULL/0/value/absent semantics intact.
|
||||
- Edit with no picker change preserves the VALARM (CAL-14); explicit null clears; explicit value (timed or all-day) emits the correct trigger (CAL-13).
|
||||
- sync populates the scheduler's ground-truth column from native VALARMs.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/11-per-event-reminders/11-03-SUMMARY.md` when done. Record the schema field, the hasExplicitReminder branch behavior, the sync derivation rule, and the occurrence-propagation path.
|
||||
</output>
|
||||
Reference in New Issue
Block a user