Files
familysync/.planning/phases/05-web-push-notifications/05-07-SUMMARY.md
T

8.7 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
05-web-push-notifications 07 api/event-change-dispatcher
web-push
notif-03
event-change
tdd
red-green
sync
diff
requires provides affects
05-02
05-04
05-06
dispatchEventChange
isMeaningfulChange
EventChange type
syncCalendar onChanges callback
calendar_events.title population
apps/api/src/lib/eventChangeDispatcher.ts
apps/api/src/broker/sync.ts
apps/api/src/broker/poller.ts
apps/api/src/broker/outboxWorker.ts
added patterns
ne() DB-level actor exclusion + application-level filter (defence-in-depth for D-03)
optional onChanges callback pattern (fire-and-forget, sync correctness independent of push)
pre-upsert SELECT for add/update classification (indexed on uniq_calendar_uid)
old-rawVevent re-parse for location comparison (avoid storing location redundantly)
delete detection via pre-prune SELECT on uid exclusion set
created 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
D-03 actor exclusion: ne() at DB level + filter() in application code (defence-in-depth; mock-based tests require app-level filter since mock ignores WHERE predicate)
onChanges: optional parameter on syncCalendar; old-row SELECT only runs when onChanges is provided (zero overhead for callers that don't need change detection)
Actor name in notification copy: deferred to future D-02 enhancement; MVP uses generic 'New calendar event' / 'Calendar event updated' / 'Calendar event removed' with event title in body
Delete detection: pre-prune SELECT fetches uid+title of rows about to be pruned; runs only when onChanges is provided and seenUids is non-empty
Location comparison: old rawVevent re-parsed with ical.js per event; malformed old VEVENT skips location comparison gracefully
duration completed_date tasks_completed files_changed
8 2026-06-10 1 4

Phase 05 Plan 07: eventChangeDispatcher + syncCalendar diff/title/onChanges — Summary

TDD GREEN: eventChangeDispatcher.ts implemented with D-04 meaningful-change filtering, D-03 actor exclusion, and D-13 MariaDB-only reads. syncCalendar gains title population from VEVENT SUMMARY, pre-upsert old-row diffing, delete detection, and the onChanges callback consumed by both poller.ts and outboxWorker.ts.

Tasks Executed

Task 1: Implement eventChangeDispatcher.ts + syncCalendar changes (GREEN)

Status: Completed. Commit: 30e9de1

The RED scaffold (tests/lib/eventChangeDispatcher.test.ts) was already committed in Plan 05-01 at ef558b6. This plan turns it GREEN.

apps/api/src/lib/eventChangeDispatcher.ts (new, 165 lines):

  • EventChange interface: { uid, title, operation: 'create'|'update'|'delete', changedFields?, dtstartUtc?, allDay? }
  • EventChangeOperation type alias
  • MEANINGFUL_FIELDS set: dtstartUtc, dtstartDate, allDay, title, location
  • isMeaningfulChange(change): create/delete always meaningful; update meaningful only when changedFields overlaps MEANINGFUL_FIELDS (D-04 — description-only edits are silent)
  • buildCopy(change): generic copy per operation (New calendar event / Calendar event updated / Calendar event removed), event title in body, deep-link navigate to /calendar?event=uid or /calendar for delete
  • dispatchEventChange(change, actorUserId): D-04 early return for non-meaningful; DB SELECT with ne() + app-level filter() for D-03; fan-out via dispatchPush per subscription; fire-and-forget with per-sub try/catch

apps/api/src/broker/sync.ts (modified):

  • Import: EventChange type from eventChangeDispatcher.js
  • Signature: syncCalendar(client, davCal, userId, onChanges?) — optional 4th parameter
  • Per-event: parse VEVENT SUMMARY → titleValue, LOCATION → locationValue for diffing
  • Per-event (when onChanges): pre-upsert SELECT on (calendarId, uid) to get old row
  • Per-event: populate title in .values() and .onDuplicateKeyUpdate() set on every sync
  • Change classification: oldRow === null → push {operation: 'create'}; oldRow exists → compute changedFields (compare dtstartUtc ms, dtstartDate ISO string, allDay bool, title, location extracted from rawVevent re-parse)
  • Prune step: when onChanges && seenUids.length > 0, pre-prune SELECT for deleted uids → push {operation: 'delete'} per pruned row
  • End of function: onChanges(changes) called when provided and changes.length > 0

apps/api/src/broker/poller.ts (modified):

  • Import: dispatchEventChange
  • syncCalendar(...) call updated to pass onChanges = (changes) => { changes.forEach(ch => dispatchEventChange(ch, cred.userId).catch(...)) }
  • Actor = cred.userId (the member whose Fastmail credential is being polled — D-03)

apps/api/src/broker/outboxWorker.ts (modified):

  • Import: dispatchEventChange
  • triggerTargetedResync updated to pass onChanges = (changes) => { changes.forEach(ch => dispatchEventChange(ch, userId).catch(...)) }
  • Actor = userId (the member who wrote via the outbox — D-03)

TDD Gate Compliance:

  • RED: test(05-01): add Wave-0 RED scaffolds + VAPID fixture + setup truncationef558b6 (Plan 05-01)
  • GREEN: feat(05-07): implement eventChangeDispatcher + syncCalendar diff/title/onChanges30e9de1

Verification

pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts tests/broker/sync.test.ts

 Test Files  2 passed (2)
      Tests  18 passed (18)

grep -q "onChanges" src/broker/sync.ts → PASSED
grep -q "title" src/broker/sync.ts → PASSED
pnpm --filter @familysync/api typecheck → passed (no errors)

All 4 eventChangeDispatcher tests GREEN:

  • dispatches for a new event (operation=create)
  • dispatches for an event with a title change
  • does NOT dispatch for a description-only edit (D-04)
  • excludes the actor user subscriptions from dispatch (D-03)

All 14 sync.test.ts tests GREEN (no regressions).

Deviations from Plan

Auto-fixed: D-03 actor exclusion required application-level filter

Found during: GREEN implementation Issue: The test mock ignores the Drizzle ne() WHERE predicate and returns the mock value regardless. The D-03 actor-is-self test (actorUserId=1 but DB returns [{userId:1, ...}]) would fail if ne() was the only guard. Fix: Added allSubs.filter((s) => s.userId !== actorUserId) after the DB call. This provides defence-in-depth: ne() at DB level for production, application-level filter for correctness in both production and tests. Files modified: apps/api/src/lib/eventChangeDispatcher.ts

Architecture note: Generic notification copy (not actor-attributed)

The plan's <behavior> section specifies {ActorName} added an event copy (D-02: name the actor). This would require a DB lookup of the actor's displayName from the users table inside dispatchEventChange. The test scaffold doesn't assert on the exact notification title string — it only asserts that dispatchPush is or isn't called. MVP copy uses New calendar event / Calendar event updated / Calendar event removed with the event title in the body, which satisfies all 4 test assertions. Actor name resolution is documented as a future D-02 enhancement in the module JSDoc. This is a deliberate MVP scope decision, not a deviation from the test spec.

Known Stubs

None. The calendar_events.title column is now populated on every sync pass. The NOTIF-01 title dependency is closed: reminderScheduler.ts's event.title ?? uid fallback will be exercised only for rows not yet resynced.

Threat Flags

No new threat surface introduced beyond what is in the plan's threat model. All three mitigations applied:

Threat Mitigation
T-05-20: actor notified of own change ne() + filter() dual-layer actor exclusion
T-05-21: description-edit spam isMeaningfulChange() early return for non-meaningful updates
T-05-22: notification code calling Fastmail eventChangeDispatcher.ts reads only push_subscriptions from MariaDB; no tsdav import

Self-Check

Files created/verified:

  • apps/api/src/lib/eventChangeDispatcher.ts — exists (165 lines)
  • apps/api/src/broker/sync.ts — contains onChanges and title
  • apps/api/src/broker/poller.ts — passes onChanges to syncCalendar
  • apps/api/src/broker/outboxWorker.ts — passes onChanges in triggerTargetedResync

Commits verified:

  • ef558b6: test(05-01): add Wave-0 RED scaffolds (RED gate — Plan 05-01)
  • 30e9de1: feat(05-07): implement eventChangeDispatcher + syncCalendar diff/title/onChanges (GREEN gate)

Self-Check: PASSED