import { eq, and, gte, desc } from "drizzle-orm"; import { alerts, alertRules } from "../../db/schema"; import type { DrizzleDB } from "../../db/config/migrations"; import type { NewAlert, Alert } from "../../db/schema"; import { checkKPIAgainstThreshold, type KPIKey } from "./kpi-service"; export interface SlackConfig { webhookUrl: string; defaultChannel?: string; } export interface AlertResult { triggered: boolean; alert?: Alert; ruleName: string; severity: "low" | "medium" | "high" | "critical"; } export async function evaluateAlertRules( db: DrizzleDB, kpiKey: KPIKey, currentValue: number ): Promise { const activeRules = await db .select() .from(alertRules) .where(and( eq(alertRules.kpiKey, kpiKey), eq(alertRules.isActive, true) )); const results: AlertResult[] = []; for (const rule of activeRules) { const triggered = isRuleTriggered(rule, currentValue); if (!triggered) { results.push({ triggered: false, ruleName: rule.name, severity: rule.severity }); continue; } const cooldownMs = rule.cooldownMinutes * 60 * 1000; const cooldownCutoff = new Date(Date.now() - cooldownMs); const recentAlert = await db .select({ id: alerts.id }) .from(alerts) .where(and( eq(alerts.ruleId, rule.id), gte(alerts.createdAt, cooldownCutoff) )) .orderBy(desc(alerts.createdAt)) .limit(1); if (recentAlert.length > 0) { results.push({ triggered: false, ruleName: rule.name, severity: rule.severity }); continue; } const severity = mapSeverity(rule, currentValue, kpiKey); const message = formatAlertMessage(rule, currentValue, kpiKey); const newAlert: NewAlert = { ruleId: rule.id, kpiKey, kpiValue: currentValue, threshold: rule.threshold, severity, message, wasSent: false, }; const result = await db.insert(alerts).values(newAlert).returning(); const alert = result[0]; results.push({ triggered: true, alert, ruleName: rule.name, severity }); } return results; } function isRuleTriggered(rule: typeof alertRules.$inferSelect, value: number): boolean { switch (rule.condition) { case "above": return value > rule.threshold; case "below": return value < rule.threshold; case "equals": return value === rule.threshold; case "increasing": return value > rule.threshold; case "decreasing": return value < rule.threshold; default: return false; } } function mapSeverity( rule: typeof alertRules.$inferSelect, _value: number, kpiKey: KPIKey ): "low" | "medium" | "high" | "critical" { const { severity: kpiSeverity } = checkKPIAgainstThreshold(kpiKey, _value); if (kpiSeverity === "critical") return "critical"; if (kpiSeverity === "warning") return rule.severity === "critical" ? "high" : "medium"; return rule.severity; } export function formatAlertMessage( rule: typeof alertRules.$inferSelect, value: number, kpiKey: KPIKey ): string { const formattedValue = Number.isInteger(value) ? value.toString() : value.toFixed(2); const formattedThreshold = Number.isInteger(rule.threshold) ? rule.threshold.toString() : rule.threshold.toFixed(2); return `${rule.name}: ${kpiKey} is ${formattedValue} (${rule.condition} ${formattedThreshold})`; } export async function sendSlackAlert( config: SlackConfig, alert: Alert, overrideChannel?: string ): Promise { const channel = overrideChannel || config.defaultChannel || "#alerts"; const color = alertSeverityToSlackColor(alert.severity); const blocks = [ { type: "header", text: { type: "plain_text", text: `🚨 KPI Alert: ${alert.kpi_key}`, emoji: true, }, }, { type: "section", fields: [ { type: "mrkdwn", text: `*Severity:*\n${alert.severity.toUpperCase()}`, }, { type: "mrkdwn", text: `*Current Value:*\n${alert.kpi_value.toFixed(2)}`, }, { type: "mrkdwn", text: `*Threshold:*\n${alert.threshold.toFixed(2)}`, }, { type: "mrkdwn", text: `*Time:*\n${new Date(alert.created_at?.getTime() ?? Date.now()).toISOString()}`, }, ], }, { type: "section", text: { type: "mrkdwn", text: alert.message, }, }, ]; const payload = { channel, blocks, username: "FrenoCorp Alerts", icon_emoji: ":warning:", }; try { const response = await fetch(config.webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!response.ok) { console.error(`Slack webhook error: ${response.status} ${response.statusText}`); return false; } await markAlertSent(alert.id); return true; } catch (error) { console.error("Failed to send Slack alert:", error); return false; } } async function markAlertSent(alertId: number): Promise { const db = await getDb(); if (db) { await db .update(alerts) .set({ wasSent: true, sentAt: new Date() }) .where(eq(alerts.id, alertId)); } } async function getDb(): Promise { try { const { createDatabaseManager } = await import("../../db/config/database"); const manager = createDatabaseManager(); return manager.getDb(); } catch { return undefined; } } function alertSeverityToSlackColor( severity: "low" | "medium" | "high" | "critical" ): string { switch (severity) { case "critical": return "#FF0000"; case "high": return "#FFA500"; case "medium": return "#FFFF00"; case "low": return "#00FF00"; default: return "#808080"; } } export async function acknowledgeAlert( db: DrizzleDB, alertId: number, acknowledgedBy: number ): Promise { const result = await db .update(alerts) .set({ acknowledgedBy, acknowledgedAt: new Date() }) .where(eq(alerts.id, alertId)) .returning(); return result[0] ?? null; } export async function getUnsentAlerts(db: DrizzleDB): Promise { return await db .select() .from(alerts) .where(eq(alerts.wasSent, false)) .orderBy(alerts.createdAt); }