/** * listChangeDispatcher — access-scoped, self-suppressed, coalesced list-change * push notifications (NOTIF-02, D-01/D-02/D-03). * * Plugs into the same publish points as the SSE fan-out (publishListEvent). * Called after each meaningful list/item mutation; excluded for reorder (position) * changes (D-01). * * Threat mitigations: * T-05-14: audience = list owner ∪ list_shares only — never all users. * T-05-15: D-02 generic copy — no item text in notification body. * T-05-16: D-03 excludeUserId = actorId — actor never receives their own push. * * Design notes: * - notifyListChange is fire-and-forget (never awaited at call site). * - dispatchPush is called per subscription; one failed send never aborts the loop. * - coalesceListPush handles burst collapsing; this module owns audience + copy. */ import { eq, inArray } from 'drizzle-orm'; import { db } from '../db/client.js'; import { users, lists, listShares, pushSubscriptions } from '../db/schema.js'; import { coalesceListPush } from './pushCoalescer.js'; import { dispatchPush } from './pushDispatcher.js'; /** * Notify all accessible, non-actor subscribers that a list changed. * * Coalesces bursts per (list, actor) — calls within the window are batched * into a single push carrying the change count (D-01). * * @param listId - The list that changed. * @param actorId - The user who made the change. Their own subscriptions are * never dispatched (D-03 self-suppression). * @param windowMs - Coalesce window in ms (default 45 s). Override in tests * for fast fake-timer or real-timer test execution. */ export function notifyListChange(listId: number, actorId: number, windowMs?: number): void { coalesceListPush( listId, actorId, async (coalListId, coalActorId, count) => { try { await sendListChangePush(coalListId, coalActorId, count); } catch (err: unknown) { console.error( `[listChangeDispatcher] unhandled error for list ${coalListId}:`, err instanceof Error ? err.message : String(err), ); } }, windowMs, ); } /** * Inner dispatch: resolves actor name + list name, builds the audience, * and sends a push to every accessible non-actor subscriber. */ async function sendListChangePush(listId: number, actorId: number, count: number): Promise { // Resolve actor display name and list name in parallel const [actorRow, listRow] = await Promise.all([ db.select({ displayName: users.displayName }).from(users).where(eq(users.id, actorId)).limit(1), db.select({ name: lists.name }).from(lists).where(eq(lists.id, listId)).limit(1), ]); if (!listRow[0]) { // List deleted between mutation and coalesce fire — no-op return; } const actorName: string = actorRow[0]?.displayName ?? 'Someone'; const listName: string = listRow[0].name; // Build audience: list owner ∪ list_shares members, MINUS the actor (D-03) const [ownerRows, shareRows] = await Promise.all([ db.select({ ownerId: lists.ownerId }).from(lists).where(eq(lists.id, listId)).limit(1), db.select({ userId: listShares.userId }).from(listShares).where(eq(listShares.listId, listId)), ]); if (!ownerRows[0]) { // List gone — no-op return; } const ownerId = ownerRows[0].ownerId; const shareUserIds = shareRows.map((r) => r.userId); // Union of owner + sharees; deduplicate; exclude actor (D-03) const audienceIds = [...new Set([ownerId, ...shareUserIds])].filter((uid) => uid !== actorId); if (audienceIds.length === 0) { return; } // Load push subscriptions for all audience members const subs = await db .select() .from(pushSubscriptions) .where(inArray(pushSubscriptions.userId, audienceIds)); if (subs.length === 0) { return; } // D-02 generic copy: "{Actor} made {N} changes to {ListName}" // No item text — keeps the lock screen clean. const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`; const notification = { title: listName, body, tag: `list-change:${listId}`, navigate: `/lists/${listId}`, }; // Fan out to each subscription; one failure must not abort the rest (T-05-04) for (const sub of subs) { await dispatchPush(sub, notification).catch((err: unknown) => { console.error( `[listChangeDispatcher] dispatchPush failed for sub ${sub.id}:`, err instanceof Error ? err.message : String(err), ); }); } }