Files
familysync/apps/api/src/lib/listChangeDispatcher.ts
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

126 lines
4.5 KiB
TypeScript
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.
/**
* 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<void> {
// 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),
);
});
}
}