Files
familysync/apps/api/src/lib/eventChangeDispatcher.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

170 lines
6.1 KiB
TypeScript

/**
* eventChangeDispatcher — push notification dispatch for calendar event changes.
*
* Responsibilities (NOTIF-03, D-03, D-04, D-13):
* - dispatchEventChange(change, actorUserId): build copy per UI-SPEC, fan out to
* all non-actor members' push subscriptions.
* - isMeaningfulChange(changedFields): true when the change involves a meaningful
* field (dtstartUtc/dtstartDate/allDay/title/location). Description-only edits
* are silent (D-04). Create and delete are always meaningful.
*
* D-03: The actor (member whose sync detected/wrote the change) is NEVER notified.
* D-13: Reads only from MariaDB push_subscriptions — no tsdav / Fastmail I/O here.
* D-11: Every push displays a visible notification (no silent pushes).
*
* Fire-and-forget: sync correctness does not depend on push success.
*/
import { eq, ne } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, pushSubscriptions } from '../db/schema.js';
import { dispatchPush } from './pushDispatcher.js';
// ── Types ────────────────────────────────────────────────────────────────────
export type EventChangeOperation = 'create' | 'update' | 'delete';
/**
* Meaningful fields whose change triggers a push notification (D-04).
* Description-only edits stay silent.
*/
export const MEANINGFUL_FIELDS = new Set([
'dtstartUtc',
'dtstartDate',
'allDay',
'title',
'location',
]);
/**
* Payload describing a detected calendar event change.
* Produced by syncCalendar and consumed by poller + outboxWorker.
*/
export interface EventChange {
uid: string;
title: string | null;
operation: EventChangeOperation;
/** For 'update': which fields changed. Omit for 'create' and 'delete'. */
changedFields?: string[];
/** UTC timestamp of the event start (for notification copy). */
dtstartUtc?: Date | null;
allDay?: boolean;
}
// ── Core logic ───────────────────────────────────────────────────────────────
/**
* Returns true when the change should trigger a push notification.
*
* - 'create' and 'delete': always meaningful.
* - 'update': meaningful only when changedFields contains at least one field
* in MEANINGFUL_FIELDS (title, dtstartUtc, dtstartDate, allDay, location).
* Description-only edits are silent (D-04).
*/
export function isMeaningfulChange(change: EventChange): boolean {
if (change.operation === 'create' || change.operation === 'delete') {
return true;
}
// update: require at least one meaningful field
const fields = change.changedFields ?? [];
return fields.some((f) => MEANINGFUL_FIELDS.has(f));
}
/**
* Builds the notification copy for a change (D-02/D-03).
*
* @param change - The detected calendar event change.
* @param actorName - Display name of the actor (D-03: named in every notification).
* Falls back to 'A family member' when the users row is absent.
*/
function buildCopy(
change: EventChange,
actorName: string,
): { notifTitle: string; notifBody: string; navigate: string } {
const eventTitle = change.title ?? change.uid;
let notifTitle: string;
let notifBody: string;
// D-02: event notifications show specifics — actor + title.
// D-03: name the actor in every change notification.
if (change.operation === 'create') {
notifTitle = `${actorName} added an event`;
notifBody = eventTitle;
} else if (change.operation === 'delete') {
notifTitle = `${actorName} removed an event`;
notifBody = eventTitle;
} else {
// update
notifTitle = `${actorName} updated an event`;
notifBody = eventTitle;
}
// Navigate: /calendar?event=uid for create/update; /calendar for delete
const navigate =
change.operation === 'delete'
? '/calendar'
: `/calendar?event=${encodeURIComponent(change.uid)}`;
return { notifTitle, notifBody, navigate };
}
/**
* Dispatch a push notification for a calendar event change to all non-actor members.
*
* D-03: actorUserId is excluded from the fan-out.
* D-04: description-only edits (isMeaningfulChange=false) are silent.
* D-13: reads only push_subscriptions from MariaDB — no tsdav or Fastmail I/O.
* D-11: every push has a visible title + body (no silent pushes).
*
* Fire-and-forget: resolves after dispatching without awaiting push ACKs.
*/
export async function dispatchEventChange(change: EventChange, actorUserId: number): Promise<void> {
// D-04: skip description-only edits
if (!isMeaningfulChange(change)) {
return;
}
// IN-01: resolve actor display name for D-02/D-03 notification copy.
// Runs in parallel with subscription query for minimal latency.
const [actorRows, allSubs] = await Promise.all([
db
.select({ displayName: users.displayName })
.from(users)
.where(eq(users.id, actorUserId))
.limit(1),
// D-13: query push_subscriptions from MariaDB only
// D-03: ne() filter excludes the actor at DB level; application-level filter
// below provides defence-in-depth (also makes the mock-based tests deterministic).
db.select().from(pushSubscriptions).where(ne(pushSubscriptions.userId, actorUserId)),
]);
const actorName: string = actorRows[0]?.displayName ?? 'A family member';
// D-03: additional application-level actor exclusion (defence-in-depth)
const subs = allSubs.filter((s) => s.userId !== actorUserId);
if (subs.length === 0) {
return;
}
const { notifTitle, notifBody, navigate } = buildCopy(change, actorName);
// Fan out to all non-actor subscriptions (D-03 already enforced by ne() filter)
for (const sub of subs) {
try {
await dispatchPush(sub, {
title: notifTitle,
body: notifBody,
tag: `event-change-${change.uid}`,
navigate,
});
} catch (err) {
console.error(
`[eventChangeDispatcher] Error dispatching for uid=${change.uid} sub.id=${sub.id}:`,
err instanceof Error ? err.message : String(err),
);
}
}
}