/** * Resolving an alert to the people it should page. * * The escalation policy is a property of the MONITOR, and it is read here on * every sweep rather than copied onto the alert when the alert is created. * That ordering is the whole point: an operator only reaches for * `escalation-policy assign` once something is already firing and reaching * nobody, so a policy that only applied to alerts created afterwards would be * a no-op for the one alert they were trying to fix (#481). * * The cost of resolving late is that a policy edited mid-escalation changes * where the remaining steps go. That is the behaviour an operator asks for * when they edit it — the alert's own progress through the chain * (`escalationStep`, `nextEscalationAt`) is what stays on the alert. * * Lives here rather than in the sweep command so the sweep's tests can drive * the real resolution instead of a fake, which is how the snapshot bug * survived: every existing test stubbed `notifyDepsFor`. */ import { eq } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { type Alert, type EscalationPolicy, escalationPolicies, monitors } from '../../db/schema'; import type { NotifyDeps } from './notifier'; import { listPeople, listPolicySteps, listRoutes } from './people'; import { mintDelivery } from './tokens'; import { loadNotificationTransport } from './transport-loader'; /** * How long a reply token stays valid. A day is long enough that someone who * sees a page overnight can still act on it in the morning, and short enough * that a token found later is useless. */ const TOKEN_TTL_MS = 24 * 60 * 60_000; /** * The escalation policy an alert resolves to right now, or null. * * The single answer to "who would this page", shared by the sweep and by every * command that has to show it. Two readers computing it separately is how a * CLI ends up confidently reporting a policy the sweep does not use. */ export function policyForAlert( db: DbClient, alert: Pick, ): EscalationPolicy | null { const monitor = db.select().from(monitors).where(eq(monitors.id, alert.monitorId)).get(); if (!monitor?.escalationPolicyId) return null; return ( db .select() .from(escalationPolicies) .where(eq(escalationPolicies.id, monitor.escalationPolicyId)) .get() ?? null ); } /** * Assemble everything needed to page for one alert, or null when nobody can be. * * Returning null rather than an empty policy is deliberate: "no escalation * policy assigned" and "policy exists but nobody is eligible" are different * situations, and only the second is worth an escalation decision. */ export function buildNotifyDeps(db: DbClient, alert: Alert, now: Date): NotifyDeps | null { const policy = policyForAlert(db, alert); if (!policy) return null; const steps = listPolicySteps(db, policy.id).map((step) => ({ stepIndex: step.stepIndex, routeId: step.routeId, delayMinutes: step.delayMinutes, })); if (steps.length === 0) return null; const routeRows = listRoutes(db); const people = listPeople(db); return { steps, routes: new Map( routeRows.map((r) => [ r.id, { id: r.id, severityFloor: r.severityFloor, enabled: r.enabled }, ]), ), routeDetails: new Map(routeRows.map((r) => [r.id, r])), quietHoursByPerson: new Map( people .filter((p) => p.quietHoursStart && p.quietHoursEnd) .map((p) => [ p.id, { personId: p.id, start: p.quietHoursStart, end: p.quietHoursEnd, timezone: p.timezone, }, ]), ), bypassQuietHours: policy.bypassQuietHours, transportFor: (route) => loadNotificationTransport(db, route.transportModuleId), mintToken: (alertId, routeId) => mintDelivery(db, { kind: 'alert', targetId: alertId, routeId, now, ttlMs: TOKEN_TTL_MS, }).token, now, }; }