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

8.7 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
05-web-push-notifications 05 execute 4
05-02
05-03
05-04
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
true
NOTIF-02
truths artifacts key_links
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
path provides exports min_lines
apps/api/src/lib/listChangeDispatcher.ts notifyListChange(listId, actorId) — resolves actor name + list name + accessible subscriptions, calls coalesceListPush
notifyListChange
25
from to via pattern
apps/api/src/routes/lists.ts apps/api/src/lib/listChangeDispatcher.ts notifyListChange called at each meaningful mutation (not reorder) notifyListChange
from to via pattern
apps/api/src/lib/listChangeDispatcher.ts apps/api/src/lib/pushCoalescer.ts coalesceListPush with accessible-subscription dispatch + excludeUserId=actorId coalesceListPush
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.

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_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 Task 1: listChangeDispatcher — access-scoped, self-suppressed fan-out - 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) - 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. 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. cd apps/api && pnpm exec vitest run tests/lib/listChangeDispatcher.test.ts Test green: burst→one push count=N to the non-actor accessible member; actor suppressed; unrelated member excluded; empty audience no-op. Access-scoped, self-suppressed, coalesced list-change dispatch implemented + tested. Task 2: Hook notifyListChange into list/item mutations (reorder excluded) - 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) 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. cd apps/api && grep -q "notifyListChange" src/routes/lists.ts && pnpm exec vitest run tests/routes/lists.test.ts 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. List mutations push (coalesced) for the other member; reorder stays silent.

<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>
- listChangeDispatcher.test.ts + lists.test.ts green. - `pnpm --filter @familysync/api typecheck` passes.

<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>
Create `.planning/phases/05-web-push-notifications/05-05-SUMMARY.md` when done.