125 lines
9.1 KiB
Markdown
125 lines
9.1 KiB
Markdown
---
|
|
phase: 05-web-push-notifications
|
|
plan: 07
|
|
type: tdd
|
|
wave: 5
|
|
depends_on: [05-02, 05-04, 05-06]
|
|
files_modified:
|
|
- apps/api/src/lib/eventChangeDispatcher.ts
|
|
- apps/api/src/broker/sync.ts
|
|
- apps/api/src/broker/poller.ts
|
|
- apps/api/src/broker/outboxWorker.ts
|
|
- apps/api/tests/lib/eventChangeDispatcher.test.ts
|
|
- apps/api/tests/broker/sync.test.ts
|
|
autonomous: true
|
|
requirements: [NOTIF-03]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "When the other member adds or meaningfully changes an event, the first member receives a push with the event title + action (NOTIF-03, D-02/D-03)"
|
|
- "Meaningful = new event, deletion, or change to time/date/title/location; description-only edits are silent (D-04)"
|
|
- "The actor (the member whose sync detected/wrote the change) is never notified of their own change (D-03)"
|
|
- "calendar_events.title is populated from VEVENT SUMMARY during sync so reminder + change copy show a readable title (NOTIF-01 dependency closed)"
|
|
- "syncCalendar exposes detected changes via a callback consumed by both the poller (external changes) and the outbox resync (this-member writes)"
|
|
- "D-13: event-change detection reads only from the MariaDB cache / poller / outbox — no tsdav or direct Fastmail I/O in eventChangeDispatcher (broker stays the sole Fastmail boundary, carried from Phase 3 D-12)"
|
|
artifacts:
|
|
- path: "apps/api/src/lib/eventChangeDispatcher.ts"
|
|
provides: "dispatchEventChange(change, actorUserId) — builds copy, fans out to non-actor members"
|
|
exports: ["dispatchEventChange", "isMeaningfulChange"]
|
|
min_lines: 35
|
|
- path: "apps/api/src/broker/sync.ts"
|
|
provides: "syncCalendar populates title + emits added/updated/deleted change records via onChanges callback"
|
|
contains: "title"
|
|
key_links:
|
|
- from: "apps/api/src/broker/sync.ts"
|
|
to: "apps/api/src/lib/eventChangeDispatcher.ts"
|
|
via: "onChanges callback dispatches detected event changes"
|
|
pattern: "onChanges"
|
|
- from: "apps/api/src/lib/eventChangeDispatcher.ts"
|
|
to: "apps/api/src/lib/pushDispatcher.ts"
|
|
via: "dispatchPush to each non-actor member subscription"
|
|
pattern: "dispatchPush"
|
|
---
|
|
|
|
<objective>
|
|
TDD NOTIF-03: when the other member adds or meaningfully changes a calendar event, push a specific, actor-attributed notification (title + action). Detect changes inside syncCalendar by diffing old vs new rows; surface them via an onChanges callback consumed by the poller (external changes) and the outbox resync (this-member writes). Populate calendar_events.title from VEVENT SUMMARY in the same pass (closes the NOTIF-01 title dependency).
|
|
|
|
Purpose: syncCalendar currently does a silent upsert with no change signal (RESEARCH Open Question 1). Adding a diff-and-callback is the chosen hook strategy: meaningful-field filtering (D-04) and actor self-suppression (D-03) are the correctness guarantees. Event copy is SPECIFIC (D-02) unlike list copy.
|
|
|
|
Output: eventChangeDispatcher.ts (dispatchEventChange + isMeaningfulChange); syncCalendar diff + title population + onChanges; poller + outboxWorker pass the dispatch callback. Turns the Plan 05-01 RED scaffold GREEN.
|
|
</objective>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@apps/api/src/broker/sync.ts
|
|
@apps/api/src/broker/poller.ts
|
|
@apps/api/src/broker/outboxWorker.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>eventChangeDispatcher + syncCalendar change detection</name>
|
|
<files>apps/api/src/lib/eventChangeDispatcher.ts, apps/api/src/broker/sync.ts, apps/api/src/broker/poller.ts, apps/api/src/broker/outboxWorker.ts, apps/api/tests/lib/eventChangeDispatcher.test.ts, apps/api/tests/broker/sync.test.ts</files>
|
|
<read_first>
|
|
- apps/api/src/broker/sync.ts (full upsert + prune flow; ICAL parse lines 82-112; the onDuplicateKeyUpdate at lines 114-138; prune lines 148-154)
|
|
- apps/api/src/broker/poller.ts (syncCalendar call line 67)
|
|
- apps/api/src/broker/outboxWorker.ts (triggerTargetedResync → syncCalendar line 171)
|
|
- apps/api/src/db/schema.ts (calendarEvents fields incl. new title; pushSubscriptions; users)
|
|
- apps/api/src/lib/pushDispatcher.ts (dispatchPush/buildPushBody)
|
|
- .planning/phases/05-web-push-notifications/05-RESEARCH.md (Open Question 1 hook strategy; D-04 meaningful fields)
|
|
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (### Event change copy — new/updated/deleted title+body templates, time format rule)
|
|
</read_first>
|
|
<behavior>
|
|
- isMeaningfulChange(oldRow, newRow): true when dtstartUtc, dtstartDate, allDay, title, or LOCATION changed; false when only the description (or only etag/updatedAt) changed. Extract title + location via ICAL SUMMARY/LOCATION from rawVevent for comparison. New event (no oldRow) → meaningful (added). Pruned uid (oldRow, no newRow) → meaningful (deleted).
|
|
- syncCalendar gains an optional onChanges?: (changes: EventChange[]) => void param. Before each upsert, SELECT the existing row for (calendarId, uid); after, classify added/updated/deleted; populate the new `title` column from the parsed SUMMARY on every upsert; collect EventChange = { kind:'added'|'updated'|'deleted', uid, title, dtstartUtc, allDay }; at the end call onChanges(changes) when provided and non-empty.
|
|
- dispatchEventChange(change, actorUserId): skip all-day-only reminder paths (this is change-notify, all-day events DO get change notifications — only reminders exclude all-day). Build copy per UI-SPEC: added → title "{ActorName} added an event", body "{EventTitle} · {when}"; updated → "{ActorName} updated an event"; deleted → "{ActorName} removed an event", body "{EventTitle}". navigate /calendar?date=…&event=uid (or /calendar for delete). Fan out to ALL members EXCEPT actorUserId, loading their push_subscriptions, dispatchPush each. {when} formatted from dtstartUtc in member-local tz per UI-SPEC time format rule.
|
|
- poller passes onChanges = (changes) => changes.forEach(ch => dispatchEventChange(ch, cred.userId)) — actor = the member whose credential synced (external write arriving). outboxWorker's triggerTargetedResync passes onChanges with actor = the userId who wrote (so the OTHER member is notified).
|
|
Cases: new event via sync → push to non-actor; time change → push; title change → push; location change → push; description-only change → NO push (D-04); actor excluded; all-day new event → push (change-notify allows all-day).
|
|
</behavior>
|
|
<implementation>
|
|
Define EventChange + EventChangeKind in eventChangeDispatcher.ts (or a small shared type). For the old-vs-new diff in syncCalendar, do a per-uid SELECT before upsert (the loop already runs per object; one extra indexed lookup on (calendarId, uid) is cheap). Parse SUMMARY/LOCATION with ICAL.Component the same way dtstart is parsed. Mock dispatchPush in eventChangeDispatcher.test.ts; test syncCalendar diff classification in sync.test.ts with a mocked db + onChanges spy (or real DB harness). Log with '[eventChangeDispatcher]'. Keep dispatch fire-and-forget; sync correctness must never depend on push success.
|
|
</implementation>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm exec vitest run tests/lib/eventChangeDispatcher.test.ts tests/broker/sync.test.ts && grep -q "onChanges" src/broker/sync.ts && grep -q "title" src/broker/sync.ts</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
Tests green: added/time/title/location → push to non-actor; description-only → silent (D-04); actor excluded (D-03); deleted → "removed" copy; title column populated; onChanges consumed by poller + outbox.
|
|
</acceptance_criteria>
|
|
</feature>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| sync diff → push audience | change eligibility + actor identity come from the sync context, not a request |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-05-20 | Spoofing | actor notified of own event change | mitigate | dispatchEventChange excludes actorUserId; poller/outbox supply the correct actor |
|
|
| T-05-21 | Denial of Service | description-edit spam | mitigate | isMeaningfulChange filters description-only edits (D-04) — no push |
|
|
| T-05-22 | Information Disclosure | event change in notification code calling Fastmail | mitigate | D-13: notification code reads MariaDB cache only; no tsdav in eventChangeDispatcher |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- RED precedes GREEN; eventChangeDispatcher.test.ts + sync.test.ts green.
|
|
- poller.ts + outboxWorker.ts pass onChanges to syncCalendar.
|
|
- `pnpm --filter @familysync/api typecheck` passes; existing sync/poller/outbox tests still green.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Failing tests committed (RED).
|
|
- eventChangeDispatcher + syncCalendar diff/title/onChanges implemented (GREEN).
|
|
- Meaningful-only (D-04), actor-suppressed (D-03), specific copy (D-02), title populated — all verified.
|
|
</success_criteria>
|
|
|
|
<output>
|
|
Create `.planning/phases/05-web-push-notifications/05-07-SUMMARY.md` with RED/GREEN commits.
|
|
</output>
|