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.
@.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