8.5 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 | 05 | api/list-change-dispatcher |
|
|
|
|
|
|
Phase 05 Plan 05: listChangeDispatcher — NOTIF-02 List-Change Push — Summary
TDD RED→GREEN: listChangeDispatcher.ts (notifyListChange) implemented; hooked into all meaningful list/item mutation points in routes/lists.ts; reorder (position) changes excluded; 64 tests GREEN.
Tasks Executed
Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out
Status: Completed.
Commits:
- RED:
test(05-05): add failing tests for listChangeDispatcher — RED gate—97f7026 - GREEN:
feat(05-05): implement listChangeDispatcher — access-scoped, self-suppressed, coalesced push (NOTIF-02)—6923104
Created apps/api/src/lib/listChangeDispatcher.ts exporting notifyListChange(listId, actorId, windowMs?):
notifyListChange — wraps coalesceListPush with a dispatch closure that:
- Resolves actor
displayNameand listnamefrom DB in parallel - Builds audience:
{list owner} ∪ {list_shares.userId} MINUS actorId(D-03) - Loads
push_subscriptionsfor all audience members - Calls
dispatchPush(sub, notification)per subscription — one failure never aborts the loop - D-02 generic copy:
"{Actor} made {N} changes to {ListName}"— no item text
Threat mitigations:
- T-05-14: audience derived from list access (owner + list_shares only) — never all users
- T-05-15: notification body carries actor name + count, no item text (D-02)
- T-05-16: actorId filtered before audience union → actor's own subscriptions never dispatched (D-03)
Tests (5/5 GREEN):
- Burst coalescing: 3 rapid calls → 1
dispatchPushto non-actor withcount=3, body contains actor name + "3" - D-03 self-suppression: actor-only list → 0 dispatches
- T-05-14 access scoping: unrelated 3rd user (no owner/share) → never dispatched
- Empty audience (no other members) → no dispatch, no crash
- Empty audience (other has no subscription) → no dispatch, no crash
Task 2: Hook notifyListChange into list/item mutations (reorder excluded)
Status: Completed.
Commit: feat(05-05): hook notifyListChange into list/item mutations (reorder excluded) — d2ce4e0
apps/api/src/routes/lists.ts updated — notifyListChange called (fire-and-forget) after each meaningful mutation:
| Route | Mutation | Push? |
|---|---|---|
POST /api/lists/:id/items |
Item added | YES |
PATCH /api/list-items/:itemId |
checked/text change | YES |
PATCH /api/list-items/:itemId |
position change (reorder) | NO (D-01) |
DELETE /api/list-items/:itemId |
Item deleted | YES |
PATCH /api/lists/:id |
List rename/sharing toggle | YES |
DELETE /api/lists/:id |
List deleted | YES |
POST /api/lists |
List created | NO (empty list, D-01 spirit) |
Critical D-01 guard in PATCH /list-items/:itemId:
if (patch.position === undefined) {
notifyListChange(item.listId, currentUserId)
}
New tests in lists.test.ts (2 tests):
PATCH { position }(reorder) does NOT callnotifyListChange— spy confirms 0 callsPATCH { checked: true }DOES callnotifyListChange(listId, ownerId)— spy confirms 1 call with correct args
Final test count: 59/59 lists.test.ts + 5/5 listChangeDispatcher.test.ts = 64/64 GREEN
Verification
pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts tests/routes/lists.test.ts
Test Files 2 passed (2)
Tests 64 passed (64)
pnpm --filter @familysync/api typecheck — passes (no errors).
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] Fake timer + real DB I/O race condition in listChangeDispatcher tests
- Found during: Task 1 (GREEN phase, first test run)
- Issue:
vi.useFakeTimers()+vi.runAllTimersAsync()fires the coalescer timer but returns before the subsequent real DB queries (sendListChangePush) complete. This caused the "burst coalesces" and "access scoping" tests to fail (0dispatchPushcalls observed even though the logic was correct). - Fix:
- Switched test approach to real timers (no
vi.useFakeTimers) with a tinywindowMs=10mspassed tonotifyListChange. - Added optional
windowMsparameter tonotifyListChange(defaults toundefined, which passes through tocoalesceListPush's 45s default) — test-only override. - Added inline
pollUntil()helper (no@testing-library/waitFordependency) that polls a predicate until it passes or a 3s timeout.
- Switched test approach to real timers (no
- Files modified:
apps/api/src/lib/listChangeDispatcher.ts,apps/api/tests/lib/listChangeDispatcher.test.ts - Commit:
6923104
2. [Rule 1 - Bug] vi.mock() top-level hoisted mock lost after vi.resetModules()
- Found during: Task 1 (first test run attempt with top-level
vi.mock) - Issue: Top-level
vi.mock('../../src/lib/pushDispatcher.js', ...)is hoisted before each test file execution, butvi.resetModules()inbeforeEachclears the module registry. When tests dynamically importedlistChangeDispatcher.js, the fresh load ofpushDispatcher.jsbypassed the mock factory. - Fix: Removed top-level
vi.mock; usedvi.doMockinsidebeforeEach(aftervi.resetModules) so each test's dynamic import oflistChangeDispatcher.jsgets a fresh mockedpushDispatcher.js. - Files modified:
apps/api/tests/lib/listChangeDispatcher.test.ts - Commit:
6923104
Known Stubs
None. notifyListChange is fully wired end-to-end. Push dispatch will fail with a logged error if VAPID keys are malformed (pre-existing infra issue from Plan 05-04, not a stub).
Threat Flags
No new threat surface beyond what the plan's threat model covers. All three threats mitigated:
| Threat | Status |
|---|---|
| T-05-14: Info disclosure — push to non-member | Mitigated — audience = owner ∪ list_shares only |
| T-05-15: Info disclosure — item text in payload | Mitigated — D-02 generic copy only |
| T-05-16: Spoofing — actor notified of own change | Mitigated — D-03 excludeUserId = actorId |
Self-Check
Files created/verified:
- apps/api/src/lib/listChangeDispatcher.ts — exists (min_lines: 25 ✓, ~110 lines)
- apps/api/tests/lib/listChangeDispatcher.test.ts — exists
Key links verified:
- apps/api/src/routes/lists.ts imports and calls
notifyListChangeat 5 mutation sites - apps/api/src/lib/listChangeDispatcher.ts calls
coalesceListPushfrompushCoalescer.ts
Commits verified:
97f7026: test(05-05): add failing tests for listChangeDispatcher — RED gate6923104: feat(05-05): implement listChangeDispatcher — VAPID send + access-scoped, self-suppressed, coalesced push (NOTIF-02)d2ce4e0: feat(05-05): hook notifyListChange into list/item mutations (reorder excluded)
TDD Gate Compliance
- RED:
test(05-05): add failing tests for listChangeDispatcher — RED gate—97f7026 - GREEN:
feat(05-05): implement listChangeDispatcher...—6923104