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

144 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 05-web-push-notifications
plan: 05
type: execute
wave: 4
depends_on: [05-02, 05-03, 05-04]
files_modified:
- apps/api/src/lib/listChangeDispatcher.ts
- apps/api/src/routes/lists.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/lib/listChangeDispatcher.test.ts
autonomous: true
requirements: [NOTIF-02]
must_haves:
truths:
- "When member A adds/checks-off/deletes/renames an item or list, the OTHER member receives a coalesced push naming the actor + list + change count (NOTIF-02, D-01/D-02/D-03)"
- "Reorder (position) PATCHes do NOT trigger any push (D-01)"
- "The actor never receives a push for their own change — fan-out filters userId != actorId (D-03)"
- "List-change pushes are scoped: only members who can access the list (owner or list_shares) get the push — never broadcast to all members"
artifacts:
- path: "apps/api/src/lib/listChangeDispatcher.ts"
provides: "notifyListChange(listId, actorId) — resolves actor name + list name + accessible subscriptions, calls coalesceListPush"
exports: ["notifyListChange"]
min_lines: 25
key_links:
- from: "apps/api/src/routes/lists.ts"
to: "apps/api/src/lib/listChangeDispatcher.ts"
via: "notifyListChange called at each meaningful mutation (not reorder)"
pattern: "notifyListChange"
- from: "apps/api/src/lib/listChangeDispatcher.ts"
to: "apps/api/src/lib/pushCoalescer.ts"
via: "coalesceListPush with accessible-subscription dispatch + excludeUserId=actorId"
pattern: "coalesceListPush"
---
<objective>
Wire NOTIF-02: a member modifying a shared list pushes a coalesced, generic, actor-attributed notification to the OTHER member. This is the list-change vertical slice on top of the push spine (05-04) and the coalescer (05-03).
Purpose: List edits are the chattiest source; D-01 coalescing + D-02 generic copy + D-03 self-suppression turn a grocery burst into a single clean ping. The dispatch hooks the SAME mutation points as the existing publishListEvent SSE fan-out, scoped to list access (never a broadcast).
Output: listChangeDispatcher.ts (notifyListChange) called from the list/item mutation handlers; reorder excluded; tests prove burst→one push, reorder-silent, self-suppression, and access scoping.
</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
@apps/api/src/routes/lists.ts
@apps/api/src/lib/pushCoalescer.ts
@apps/api/src/lib/pushDispatcher.ts
@apps/api/src/db/schema.ts
@apps/api/tests/routes/lists.test.ts
@.planning/phases/05-web-push-notifications/05-RESEARCH.md
@.planning/phases/05-web-push-notifications/05-UI-SPEC.md
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out</name>
<read_first>
- apps/api/src/routes/lists.ts (checkListAccess lines 123-153; GET access scoping lines 168-183 — owner + list_shares union; resolveUserId)
- apps/api/src/lib/pushCoalescer.ts (coalesceListPush signature)
- apps/api/src/lib/pushDispatcher.ts (dispatchPush)
- apps/api/src/db/schema.ts (lists, listShares, users, pushSubscriptions)
- .planning/phases/05-web-push-notifications/05-UI-SPEC.md (list-change copy template)
</read_first>
<behavior>
- notifyListChange(listId, actorId): resolves actorName (users.displayName via deriveDisplayName fallback) and listName (lists.name); calls coalesceListPush(listId, actorId, actorName, listName, dispatch). The injected dispatch(payload, excludeUserId) computes the accessible audience = {list owner} {list_shares.userId} MINUS excludeUserId, loads their push_subscriptions, and calls dispatchPush per subscription.
- Self-suppression: excludeUserId === actorId → actor's own subscriptions are never sent to.
- Access scoping: a member with no owner/share relationship to the list is never in the audience.
- Empty audience (no other accessible members or no subscriptions) → no dispatch, no crash.
Tests (listChangeDispatcher.test.ts, real DB per lists.test.ts harness): seed two users, a shared list, push_subscriptions for both; call notifyListChange(listId, actorA) thrice within window, advance fake timers → exactly one dispatchPush to userB (mock dispatchPush), body "3 changes", actorA never dispatched to. Seed a third unrelated user with no access → never dispatched.
</behavior>
<action>
Create apps/api/src/lib/listChangeDispatcher.ts exporting notifyListChange(listId, actorId). Reuse the owner + list_shares union access query idiom from lists.ts GET (lines 168-183) to build the audience. Mock dispatchPush in tests (vi.mock) to assert recipients without network. Use vi.useFakeTimers to drive the coalescer window. Log errors with '[listChangeDispatcher]' prefix; one failed send must not abort the loop.
</action>
<verify>
<automated>cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts</automated>
</verify>
<acceptance_criteria>
Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op.
</acceptance_criteria>
<done>Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested.</done>
</task>
<task type="auto">
<name>Task 2: Hook notifyListChange into list/item mutations (reorder excluded)</name>
<read_first>
- apps/api/src/routes/lists.ts (every publishListEvent call site: POST /:id/items line 497, PATCH /list-items/:itemId line 640, DELETE /list-items/:itemId line 691, POST / line 287, PATCH /:id line 385, DELETE /:id line 431)
- apps/api/tests/routes/lists.test.ts (existing harness for the reorder-silent assertion)
- .planning/phases/05-web-push-notifications/05-CONTEXT.md (D-01 reorder does NOT push)
</read_first>
<action>
In apps/api/src/routes/lists.ts, after each MEANINGFUL mutation's publishListEvent call, add notifyListChange(listId, currentUserId): item added (POST /:id/items), item checked/unchecked or text edited (PATCH /list-items/:itemId — but NOT when the patch was a position change), item deleted (DELETE /list-items/:itemId), list renamed (PATCH /:id), list deleted (DELETE /:id). For POST / (list created) — a fresh empty list is not a "change to a shared list" worth pinging; do NOT notify on list create (matches D-01 spirit; the create already auto-shares silently). CRITICAL (D-01): in PATCH /list-items/:itemId, when patch.position !== undefined (reorder), do NOT call notifyListChange — only checked/text changes notify. notifyListChange is fire-and-forget (do not await in a way that blocks the response; call it and catch).
Extend apps/api/tests/routes/lists.test.ts: assert that a position-only PATCH does NOT enqueue a list-change push (spy notifyListChange or the coalescer), and that a checked PATCH does.
</action>
<verify>
<automated>cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts</automated>
</verify>
<acceptance_criteria>
notifyListChange called on add/check/text-edit/delete/rename/list-delete; NOT called on reorder (position) or list-create; lists.test.ts proves reorder-silent vs check-notifies; existing list tests still green.
</acceptance_criteria>
<done>List mutations push (coalesced) for the other member; reorder stays silent.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| list mutation → push audience | the audience must be derived from list access, not the request |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-14 | Information Disclosure | list-change push to a non-member | mitigate | audience = owner list_shares only (same scope as SSE / GET /api/lists); never all users |
| T-05-15 | Information Disclosure | item text in payload | mitigate | D-02 generic copy — coalescer payload carries no item text, only actor + list name + count |
| T-05-16 | Spoofing | actor notified of own change | mitigate | excludeUserId = actorId; fan-out filters userId != actorId (D-03) |
</threat_model>
<verification>
- listChangeDispatcher.test.ts + lists.test.ts green.
- `pnpm --filter @familysync/api typecheck` passes.
</verification>
<success_criteria>
- Meaningful list/item mutations push a coalesced, generic, actor-attributed notification to accessible non-actor members.
- Reorder and list-create push nothing.
- Audience strictly scoped to list access; actor suppressed.
</success_criteria>
<output>
Create `.planning/phases/05-web-push-notifications/05-05-SUMMARY.md` when done.
</output>