/** * The notifier — turning a firing alert into a message someone receives. * * This is the seam where every earlier decision finally has consequences: * escalation says whether and whom, quiet hours say when, suppression says * nothing at all. The transport is injected so the whole loop can be driven * against a simulator, or against nothing. * * Composition, not new policy: this function makes no decisions of its own. If * it looks like it is deciding something, that logic belongs in escalation.ts * or quiet-hours.ts where it can be tested without a database. */ import type { Alert, Route } from '../../db/schema'; import { type EscalationStep, type RouteForEscalation, decideEscalation } from './escalation'; import { type QuietHoursWindow, isWithinQuietHours, quietHoursEndAfter } from './quiet-hours'; export interface NotificationTransport { send(request: { address: string; body: string; token?: string }): Promise<{ messageId: string }>; } export interface PersonWindow extends QuietHoursWindow { personId: string; } export interface NotifyDeps { /** Steps of the alert's escalation policy, in order. */ steps: EscalationStep[]; routes: Map; /** Full route rows, for address and person lookup. */ routeDetails: Map; quietHoursByPerson: Map; /** Whether the alert's policy is allowed to page through quiet hours. */ bypassQuietHours: boolean; transportFor(route: Route): NotificationTransport; mintToken(alertId: string, routeId: string): string | null; now: Date; } export type NotifyOutcome = | { result: 'sent'; routeId: string; stepIndex: number; messageId: string; nextStepDueAt: Date | null; } | { result: 'deferred'; routeId: string; until: Date; reason: 'quiet_hours' } | { result: 'skipped'; reason: string } | { result: 'failed'; routeId: string; error: string }; /** * Format the message body. * * Key first, because on a phone the first line is what shows in the * notification — an operator half-awake needs to know WHAT before WHY. */ export function composeAlertBody(alert: Pick): string { const marker = alert.severity === 'critical' ? '🔴' : '⚠️'; return `${marker} ${alert.key}\n${alert.message}`; } export function composeResolvedBody(alert: Pick): string { return `✅ RESOLVED — ${alert.key}`; } /** * Tell the others that someone has it. * * Names the acknowledger rather than just saying "acknowledged": the question * a woken secondary actually has is "do I still need to get up", and only a * name answers it. */ export function composeAckBroadcastBody(alert: Pick, ackedBy: string): string { return `👍 ${ackedBy} has ${alert.key} — no action needed.`; } /** * Notify for one alert, if it is due. * * Returns what happened rather than throwing, because "nobody was told, and * here is why" is information the caller must be able to record — a silent * skip is indistinguishable from a bug. */ export async function notifyAlert(alert: Alert, deps: NotifyDeps): Promise { const decision = decideEscalation({ alert: { id: alert.id, severity: alert.severity, escalationStep: alert.escalationStep, acked: alert.state === 'acked', resolved: alert.state === 'resolved', suppressed: alert.state === 'suppressed', awaitingConfirmation: alert.awaitingConfirmation, silencedUntil: alert.silencedUntil, graceUntil: alert.graceUntil, escalationStartedAt: alert.unsuppressedAt ?? alert.firstFiredAt, }, steps: deps.steps, routes: deps.routes, now: deps.now, }); if (decision.type === 'skip') return { result: 'skipped', reason: decision.reason }; const route = deps.routeDetails.get(decision.routeId); if (!route) return { result: 'skipped', reason: 'no_eligible_route' }; // Quiet hours defer delivery of EVERY severity (D13). The escalation step is // still considered taken — steps advance on schedule and only delivery waits, // so nothing is skipped, it arrives when the window opens. if (!deps.bypassQuietHours) { const window = deps.quietHoursByPerson.get(route.personId); if (window && isWithinQuietHours(window, deps.now)) { const until = quietHoursEndAfter(window, deps.now); if (until) return { result: 'deferred', routeId: route.id, until, reason: 'quiet_hours' }; } } // A route that cannot receive replies gets no token — a reply instruction // nobody can follow is worse than none. const token = route.canAck ? (deps.mintToken(alert.id, route.id) ?? undefined) : undefined; try { const { messageId } = await deps.transportFor(route).send({ address: route.address, body: composeAlertBody(alert), token, }); return { result: 'sent', routeId: route.id, stepIndex: decision.stepIndex, messageId, nextStepDueAt: decision.nextStepDueAt, }; } catch (error) { // A transport failure must not be silent: the operator believes they are // covered, and the only evidence otherwise is this record. return { result: 'failed', routeId: route.id, error: error instanceof Error ? error.message : String(error), }; } } /** * Deliver a message held over someone's quiet hours. * * The state re-check is the point of doing this at window end rather than * scheduling the send in advance: an alert that resolved at 03:00 must not * arrive at 07:00 announcing a problem that no longer exists. Ack and silence * count too — someone who dealt with it overnight has already been told. */ export async function deliverDeferred( alert: Alert, route: Route, deps: Pick, ): Promise { if (alert.state === 'resolved') return { result: 'skipped', reason: 'resolved' }; if (alert.state === 'acked') return { result: 'skipped', reason: 'acked' }; if (alert.state === 'suppressed') return { result: 'skipped', reason: 'suppressed' }; if (alert.silencedUntil && alert.silencedUntil > deps.now) { return { result: 'skipped', reason: 'silenced' }; } const token = route.canAck ? (deps.mintToken(alert.id, route.id) ?? undefined) : undefined; try { const { messageId } = await deps.transportFor(route).send({ address: route.address, body: composeAlertBody(alert), token, }); return { result: 'sent', routeId: route.id, stepIndex: alert.escalationStep, messageId, nextStepDueAt: alert.nextEscalationAt, }; } catch (error) { return { result: 'failed', routeId: route.id, error: error instanceof Error ? error.message : String(error), }; } } /** * Send an all-clear to every route that was told about the alert. * * Only to those routes: someone who was never paged does not need to be told * it is over, and telling them trains them to ignore the channel. */ export async function notifyResolved( alert: Pick, notifiedRoutes: Route[], deps: Pick, ): Promise<{ routeId: string; ok: boolean; error?: string }[]> { const results: { routeId: string; ok: boolean; error?: string }[] = []; for (const route of notifiedRoutes) { try { await deps.transportFor(route).send({ address: route.address, body: composeResolvedBody(alert), }); results.push({ routeId: route.id, ok: true }); } catch (error) { results.push({ routeId: route.id, ok: false, error: error instanceof Error ? error.message : String(error), }); } } return results; }