//! Inbound infra-alert log (see migration `169_admin_alerts.sql`). //! //! Backs the `POST /api/internal/alerts` ingestion endpoint: external //! monitoring agents (PoM, MT) push ops alerts here, each persisted as one row //! and emailed to the operator. Admin-routed only -- never creator-facing. use serde::Deserialize; use sqlx::PgPool; /// Alert domain an agent may report. Closed set, deserialized straight from the /// wire (`#[serde(rename_all = "snake_case")]`), so an unknown value is rejected /// when the request body is parsed, there is no runtime membership check. /// Extending the taxonomy is a variant here plus its `as_str`, not a migration /// (the column is TEXT). /// /// These are domain-level, not per-event: PoM's finer categories (tls expiry vs /// tls error vs tls recovery) all fold onto `Tls`, with the failure/recovery /// distinction carried by [`AlertSeverity`] instead. Kept small so the operator /// log filters by system, not by every sub-condition. #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AlertKind { Health, Tls, Dns, Whois, Latency, Cors, Backup, Peer, Route, Scan, Monitoring, } impl AlertKind { /// Canonical wire/storage token. Matches the `serde(rename_all)` mapping. pub fn as_str(self) -> &'static str { match self { Self::Health => "health", Self::Tls => "tls", Self::Dns => "dns", Self::Whois => "whois", Self::Latency => "latency", Self::Cors => "cors", Self::Backup => "backup", Self::Peer => "peer", Self::Route => "route", Self::Scan => "scan", Self::Monitoring => "monitoring", } } } /// Alert urgency, most to least. Closed set, deserialized like [`AlertKind`]. #[derive(Debug, Clone, Copy, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AlertSeverity { Critical, Warning, Info, } impl AlertSeverity { /// Canonical wire/storage token. pub fn as_str(self) -> &'static str { match self { Self::Critical => "critical", Self::Warning => "warning", Self::Info => "info", } } } /// A validated inbound alert, ready to persist. pub struct NewAlert<'a> { pub source: &'a str, pub kind: AlertKind, pub severity: AlertSeverity, pub title: &'a str, pub body: &'a str, pub dedup_key: Option<&'a str>, pub details: Option<&'a serde_json::Value>, } /// Insert an ingested alert and return its generated id. `emailed` starts false; /// the handler flips it via [`mark_emailed`] after the notification is sent so a /// mail failure stays visible in the log. #[tracing::instrument(skip_all, fields(source = alert.source, kind = alert.kind.as_str(), severity = alert.severity.as_str()))] pub async fn insert_alert(pool: &PgPool, alert: &NewAlert<'_>) -> Result { let id: i64 = sqlx::query_scalar( "INSERT INTO admin_alerts (source, kind, severity, title, body, dedup_key, details) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id", ) .bind(alert.source) .bind(alert.kind.as_str()) .bind(alert.severity.as_str()) .bind(alert.title) .bind(alert.body) .bind(alert.dedup_key) .bind(alert.details) .fetch_one(pool) .await?; Ok(id) } /// Mark an alert row as successfully emailed to the operator. #[tracing::instrument(skip_all)] pub async fn mark_emailed(pool: &PgPool, id: i64) -> Result<(), sqlx::Error> { sqlx::query("UPDATE admin_alerts SET emailed = true WHERE id = $1") .bind(id) .execute(pool) .await?; Ok(()) }