/** * 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 { // 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), ); } } }