use chrono::{DateTime, Duration, Utc};
use kanade_shared::ExecResult;
use kanade_shared::ipc::notifications::Notification;
use kanade_shared::manifest::{CheckAlert, CheckHint, Target};
use sqlx::SqlitePool;
use tracing::{info, warn};
use uuid::Uuid;
use crate::api::notifications::fan_out_notification;
const ALERT_LIVE_WINDOW: Duration = Duration::minutes(15);
pub(super) fn is_fresh(recorded_at: DateTime<Utc>, now: DateTime<Utc>) -> bool {
now - recorded_at < ALERT_LIVE_WINDOW
}
pub(super) fn should_fire(
alert: &CheckAlert,
prior: Option<&str>,
new_status: &str,
fresh: bool,
) -> bool {
if !fresh {
return false;
}
let is_alert = |s: &str| alert.on.iter().any(|st| st.as_str() == s);
is_alert(new_status) && !prior.is_some_and(is_alert)
}
pub(super) fn render(
template: &str,
pc_id: &str,
hint: &CheckHint,
status: &str,
detail: &str,
last_logon: &str,
) -> String {
template
.replace("{pc_id}", pc_id)
.replace("{name}", &hint.name)
.replace("{label}", hint.label.as_deref().unwrap_or(&hint.name))
.replace("{status}", status)
.replace("{detail}", detail)
.replace("{last_logon}", last_logon)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn fire(
js: &async_nats::jetstream::Context,
pool: &SqlitePool,
r: &ExecResult,
hint: &CheckHint,
alert: &CheckAlert,
status: &str,
detail: &str,
mailer: Option<&crate::mail::Mailer>,
) {
let needs_logon = alert.title.contains("{last_logon}")
|| alert
.body
.as_deref()
.is_some_and(|b| b.contains("{last_logon}"));
let last_logon = if needs_logon {
last_logon_for(pool, &r.pc_id).await
} else {
String::new()
};
let title = render(&alert.title, &r.pc_id, hint, status, detail, &last_logon);
let body = alert
.body
.as_deref()
.map(|b| render(b, &r.pc_id, hint, status, detail, &last_logon))
.unwrap_or_default();
let target = Target {
all: false,
groups: alert.notify_groups.clone(),
pcs: if alert.notify_user {
vec![r.pc_id.clone()]
} else {
Vec::new()
},
};
let notification = Notification {
id: Uuid::new_v4().to_string(),
priority: alert.priority,
require_ack: alert.require_ack,
title,
body,
toast: alert.toast,
issued_at: Utc::now(),
issued_by: Some(format!("compliance:{}", hint.name)),
expires_at: None,
acked_at: None,
edited_at: None,
acks_reset_at: None,
};
match fan_out_notification(js, ¬ification, &target).await {
Ok((delivered, failed)) => info!(
pc_id = %r.pc_id,
check = %hint.name,
status,
notification_id = %notification.id,
delivered = ?delivered,
failed = ?failed,
"compliance alert published",
),
Err(e) => warn!(
error = %e,
pc_id = %r.pc_id,
check = %hint.name,
"compliance alert: failed to publish",
),
}
if alert.email
&& let Some(mailer) = mailer
{
let mailer = mailer.clone();
let js = js.clone();
let groups = alert.notify_groups.clone();
let subject = notification.title.clone();
let body = notification.body.clone();
let pc_id = r.pc_id.clone();
let check = hint.name.clone();
let notification_id = notification.id.clone();
tokio::spawn(async move {
let to = crate::api::group_contacts::emails_for_groups(&js, &groups).await;
if to.is_empty() {
warn!(
pc_id = %pc_id,
check = %check,
"compliance alert email: no addresses for notify_groups — skipping email",
);
return;
}
match mailer.send(&to, &subject, &body).await {
Ok(()) => info!(
pc_id = %pc_id,
check = %check,
recipients = to.len(),
notification_id = %notification_id,
"compliance alert emailed",
),
Err(e) => warn!(
error = %format!("{e:#}"),
pc_id = %pc_id,
check = %check,
"compliance alert: failed to email",
),
}
});
}
}
async fn last_logon_for(pool: &SqlitePool, pc_id: &str) -> String {
let row: Option<(Option<String>, Option<String>)> = sqlx::query_as(
"SELECT last_logon_display_name, last_logon_user FROM agents WHERE pc_id = ?",
)
.bind(pc_id)
.fetch_optional(pool)
.await
.unwrap_or(None);
row.and_then(|(disp, user)| disp.or(user))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
use kanade_shared::ipc::notifications::NotificationPriority;
use kanade_shared::manifest::CheckAlertStatus;
fn alert(on: Vec<CheckAlertStatus>) -> CheckAlert {
CheckAlert {
on,
notify_user: true,
notify_groups: vec![],
priority: NotificationPriority::Warn,
require_ack: false,
toast: true,
email: false,
title: "t".into(),
body: None,
}
}
fn hint() -> CheckHint {
CheckHint {
name: "bitlocker".into(),
label: Some("BitLocker".into()),
status_field: "status".into(),
detail_field: "detail".into(),
troubleshoot: None,
fleet: true,
alert: None,
}
}
#[test]
fn fires_on_transition_into_alert_status() {
let a = alert(vec![CheckAlertStatus::Fail]);
assert!(should_fire(&a, Some("ok"), "fail", true));
assert!(should_fire(&a, None, "fail", true));
}
#[test]
fn does_not_refire_while_staying_failed() {
let a = alert(vec![CheckAlertStatus::Fail]);
assert!(!should_fire(&a, Some("fail"), "fail", true));
}
#[test]
fn does_not_fire_on_recovery_or_non_alert_status() {
let a = alert(vec![CheckAlertStatus::Fail]);
assert!(!should_fire(&a, Some("fail"), "ok", true), "fail → ok");
assert!(
!should_fire(&a, Some("ok"), "warn", true),
"warn not in `on`"
);
}
#[test]
fn stale_replay_is_suppressed() {
let a = alert(vec![CheckAlertStatus::Fail]);
assert!(!should_fire(&a, Some("ok"), "fail", false));
}
#[test]
fn multi_status_on_fires_only_on_entering_the_alert_set() {
let a = alert(vec![CheckAlertStatus::Warn, CheckAlertStatus::Fail]);
assert!(should_fire(&a, Some("ok"), "warn", true));
assert!(!should_fire(&a, Some("warn"), "fail", true));
}
#[test]
fn template_expands_all_placeholders() {
let h = hint();
let out = render(
"{label} on {pc_id} is {status}: {detail} (user {last_logon})",
"PC1",
&h,
"fail",
"D: unprotected",
"Yamada Taro",
);
assert_eq!(
out,
"BitLocker on PC1 is fail: D: unprotected (user Yamada Taro)"
);
let mut h2 = hint();
h2.label = None;
assert_eq!(render("{label}", "PC1", &h2, "fail", "", ""), "bitlocker");
}
#[test]
fn freshness_window() {
let now = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 12, 0, 0).unwrap();
assert!(is_fresh(now - Duration::minutes(5), now), "5 min ⇒ fresh");
assert!(
!is_fresh(now - Duration::minutes(30), now),
"30 min ⇒ stale"
);
}
}