144 lines
8.7 KiB
Markdown
144 lines
8.7 KiB
Markdown
---
|
||
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>
|