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