/** * Acknowledgement, silence, and manual resolution. * * Three operations that look similar and mean entirely different things * (design S5). Collapsing any two is how an alerting system stops being * trusted: * * ack a person has this. Escalation stops; the alert is still FIRING, * because the problem is still happening. * silence deliberate, expiring, audited. "I know, stop telling me until X." * resolve the operator asserts the condition is gone. The next successful * monitor run is still the authority — if it disagrees, the alert * comes straight back, which is correct. */ import { and, eq, isNull, ne } from 'drizzle-orm'; import type { DbClient } from '../../db/client'; import { type Alert, alerts, notificationDeliveries, routes } from '../../db/schema'; export interface AckResult { alert: Alert; /** Routes that were paged and should be told someone has it. */ broadcastTo: string[]; } /** * Acknowledge an alert. * * Returns every OTHER route that was paged, so the caller can tell them who * took it. Without that, a secondary who acks leaves the primary still * believing they need to act — the exact duplicated-effort the escalation * chain exists to avoid. */ export function acknowledgeAlert( db: DbClient, alertId: string, personId: string, now: Date, ): AckResult | null { const alert = db.select().from(alerts).where(eq(alerts.id, alertId)).get(); if (!alert) return null; // Acking a resolved alert is a no-op rather than an error: a reply that // arrives just after recovery is a normal race, not operator error. if (alert.state === 'resolved') return { alert, broadcastTo: [] }; db.update(alerts) .set({ state: 'acked', ackedBy: personId, ackedAt: now }) .where(eq(alerts.id, alertId)) .run(); const paged = db .select({ routeId: notificationDeliveries.routeId }) .from(notificationDeliveries) .where( and(eq(notificationDeliveries.kind, 'alert'), eq(notificationDeliveries.targetId, alertId)), ) .all(); const acknowledgerRoutes = new Set( db .select({ id: routes.id }) .from(routes) .where(eq(routes.personId, personId)) .all() .map((r) => r.id), ); // Don't tell the acknowledger about their own ack. const broadcastTo = [...new Set(paged.map((p) => p.routeId))].filter( (routeId) => !acknowledgerRoutes.has(routeId), ); const updated = db.select().from(alerts).where(eq(alerts.id, alertId)).get() as Alert; return { alert: updated, broadcastTo }; } /** * Silence an alert until a given instant. * * Distinct from ack: silence says "stop telling me", ack says "I have this". * An alert can be silenced without anyone owning it, which is exactly the * state worth being able to see later. */ export function silenceAlert(db: DbClient, alertId: string, until: Date): Alert | null { const alert = db.select().from(alerts).where(eq(alerts.id, alertId)).get(); if (!alert) return null; db.update(alerts).set({ silencedUntil: until }).where(eq(alerts.id, alertId)).run(); return db.select().from(alerts).where(eq(alerts.id, alertId)).get() as Alert; } /** * Manually resolve an alert. * * The operator's assertion, not the monitor's. If the condition is still * failing, the next successful run re-creates the alert — which is correct, * and is why this is safe to offer: it cannot be used to permanently hide a * real problem, only to clear one the operator knows is finished. */ export function resolveAlertManually(db: DbClient, alertId: string, now: Date): Alert | null { const alert = db.select().from(alerts).where(eq(alerts.id, alertId)).get(); if (!alert) return null; db.update(alerts) .set({ state: 'resolved', activeKey: null, resolvedAt: now }) .where(eq(alerts.id, alertId)) .run(); return db.select().from(alerts).where(eq(alerts.id, alertId)).get() as Alert; } /** Live alerts whose key matches, for resolving an operator-typed key. */ export function findLiveAlertByKey(db: DbClient, key: string): Alert | undefined { return db .select() .from(alerts) .where(and(eq(alerts.key, key), isNull(alerts.resolvedAt), ne(alerts.state, 'resolved'))) .get(); }