/** * Phase B alert framework — service layer. * * This is the skeleton: types, function shapes, and behaviour stubs. The * actual rule evaluators live in `alert-rules.ts` (PR2). The cron * dispatcher will compose this service with that catalogue. */ import { and, eq, isNull, sql } from 'drizzle-orm'; import { createHash } from 'crypto'; import { db } from '@/lib/db'; import { alerts, type Alert, type AlertSeverity, type AlertRuleId } from '@/lib/db/schema/insights'; import { emitToRoom } from '@/lib/socket/server'; export interface AlertCandidate { ruleId: AlertRuleId; severity: AlertSeverity; title: string; body?: string; link: string; entityType?: string; entityId?: string; metadata?: Record; } /** * Stable identity hash so re-evaluations of the same condition upsert * onto the same row (via `idx_alerts_fingerprint_open`). */ export function fingerprintFor( c: Pick, ): string { return createHash('sha1') .update(`${c.ruleId}|${c.entityType ?? ''}|${c.entityId ?? ''}`) .digest('hex'); } /** * Apply a batch of rule outputs against the open-alert table: * - upsert open alerts (rule still firing) * - resolve any open alert in scope whose fingerprint isn't in this batch */ export async function reconcileAlertsForPort( portId: string, ruleId: AlertRuleId, candidates: AlertCandidate[], ): Promise { // Insert new / leave existing — only one open row per fingerprint // thanks to the partial unique index. Track newly inserted rows so we // can emit `alert:created` to the port room. for (const c of candidates) { const fingerprint = fingerprintFor(c); const inserted = await db .insert(alerts) .values({ portId, ruleId: c.ruleId, severity: c.severity, title: c.title, body: c.body, link: c.link, entityType: c.entityType, entityId: c.entityId, fingerprint, metadata: c.metadata ?? {}, }) .onConflictDoNothing() .returning({ id: alerts.id }); if (inserted[0]) { emitToRoom(`port:${portId}`, 'alert:created', { alertId: inserted[0].id, portId, ruleId: c.ruleId, severity: c.severity, title: c.title, link: c.link, }); } } // Auto-resolve open alerts for this rule whose fingerprint disappeared. const liveFingerprints = new Set(candidates.map((c) => fingerprintFor(c))); const open = await db.query.alerts.findMany({ where: and(eq(alerts.portId, portId), eq(alerts.ruleId, ruleId), isNull(alerts.resolvedAt)), }); const stale = open.filter((a) => !liveFingerprints.has(a.fingerprint)); for (const a of stale) { await db .update(alerts) .set({ resolvedAt: sql`now()` }) .where(eq(alerts.id, a.id)); emitToRoom(`port:${portId}`, 'alert:resolved', { alertId: a.id, portId, ruleId, }); } } export async function dismissAlert(alertId: string, userId: string): Promise { const [row] = await db .update(alerts) .set({ dismissedAt: sql`now()`, dismissedBy: userId }) .where(eq(alerts.id, alertId)) .returning({ id: alerts.id, portId: alerts.portId }); if (row) { emitToRoom(`port:${row.portId}`, 'alert:dismissed', { alertId: row.id, portId: row.portId }); } } export async function acknowledgeAlert(alertId: string, userId: string): Promise { await db .update(alerts) .set({ acknowledgedAt: sql`now()`, acknowledgedBy: userId }) .where(eq(alerts.id, alertId)); } export interface ListAlertsOptions { severity?: AlertSeverity[]; includeDismissed?: boolean; includeResolved?: boolean; } export async function listAlertsForPort( portId: string, options: ListAlertsOptions = {}, ): Promise { const conditions = [eq(alerts.portId, portId)]; if (!options.includeResolved) conditions.push(isNull(alerts.resolvedAt)); if (!options.includeDismissed) conditions.push(isNull(alerts.dismissedAt)); return db.query.alerts.findMany({ where: and(...conditions), orderBy: (a, { desc }) => [desc(a.firedAt)], limit: 100, }); }