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.
170 lines
6.1 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
}
|
|
}
|