use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::{PgConnection, PgPool};
use uuid::Uuid;
use crate::{
alerts::AlertRule,
app::AppState,
auth::AuthenticatedAgent,
error::ApiError,
login::CurrentUser,
privacy::PrivacyLevel,
webhooks::{AlertPayload, Webhooks, hours, span},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
#[sqlx(type_name = "notification_kind")]
pub enum NotificationKind {
#[serde(rename = "alert.raised")]
#[sqlx(rename = "alert.raised")]
AlertRaised,
#[serde(rename = "agent.issued")]
#[sqlx(rename = "agent.issued")]
AgentIssued,
#[serde(rename = "agent.revoked")]
#[sqlx(rename = "agent.revoked")]
AgentRevoked,
#[serde(rename = "privacy.changed")]
#[sqlx(rename = "privacy.changed")]
PrivacyChanged,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentFact {
pub id: Uuid,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrivacyFact {
pub from: PrivacyLevel,
pub to: PrivacyLevel,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Facts {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alert: Option<AlertPayload>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<AgentFact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub privacy: Option<PrivacyFact>,
}
pub async fn alert_raised(conn: &mut PgConnection, user_id: Uuid, alert: &AlertPayload) -> Result<(), sqlx::Error> {
let facts = Facts {
alert: Some(alert.clone()),
..Facts::default()
};
sqlx::query(
"INSERT INTO notifications (user_id, kind, alert_id, payload) VALUES ($1, 'alert.raised', $2, $3)
ON CONFLICT (alert_id) DO NOTHING",
)
.bind(user_id)
.bind(alert.id)
.bind(sqlx::types::Json(&facts))
.execute(conn)
.await?;
Ok(())
}
pub async fn agent_changed(conn: &mut PgConnection, kind: NotificationKind, user_id: Uuid, agent_id: Uuid, name: &str) -> Result<(), sqlx::Error> {
debug_assert!(matches!(kind, NotificationKind::AgentIssued | NotificationKind::AgentRevoked));
let facts = Facts {
agent: Some(AgentFact {
id: agent_id,
name: name.to_string(),
}),
..Facts::default()
};
sqlx::query("INSERT INTO notifications (user_id, kind, agent_id, payload) VALUES ($1, $2, $3, $4)")
.bind(user_id)
.bind(kind)
.bind(agent_id)
.bind(sqlx::types::Json(&facts))
.execute(conn)
.await?;
Ok(())
}
pub async fn privacy_changed(conn: &mut PgConnection, from: PrivacyLevel, to: PrivacyLevel) -> Result<u64, sqlx::Error> {
let facts = Facts {
privacy: Some(PrivacyFact { from, to }),
..Facts::default()
};
let written = sqlx::query("INSERT INTO notifications (user_id, kind, payload) SELECT id, 'privacy.changed', $1 FROM users WHERE active")
.bind(sqlx::types::Json(&facts))
.execute(conn)
.await?;
Ok(written.rows_affected())
}
#[derive(Debug, Serialize)]
pub struct Notification {
pub id: i64,
pub kind: NotificationKind,
pub created_at: DateTime<Utc>,
pub title: String,
pub body: String,
pub withdrawn_at: Option<DateTime<Utc>>,
pub read: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<String>,
#[serde(flatten)]
pub facts: Facts,
}
#[derive(Debug, sqlx::FromRow)]
struct Row {
id: i64,
kind: NotificationKind,
created_at: DateTime<Utc>,
payload: sqlx::types::Json<Facts>,
withdrawn_at: Option<DateTime<Utc>>,
read: bool,
}
impl Row {
fn into_notification(self, webhooks: &Webhooks) -> Notification {
let facts = self.payload.0;
let (title, body) = words(self.id, self.kind, &facts);
Notification {
id: self.id,
kind: self.kind,
created_at: self.created_at,
title,
body,
withdrawn_at: self.withdrawn_at,
read: self.read,
link: webhooks.link(where_to_look(self.kind)),
facts,
}
}
}
fn where_to_look(kind: NotificationKind) -> &'static str {
match kind {
NotificationKind::AlertRaised => "/day",
NotificationKind::PrivacyChanged => "/privacy",
NotificationKind::AgentIssued | NotificationKind::AgentRevoked => "/notifications",
}
}
pub fn words(id: i64, kind: NotificationKind, facts: &Facts) -> (String, String) {
match (kind, facts) {
(NotificationKind::AlertRaised, Facts { alert: Some(alert), .. }) => alert_words(alert),
(NotificationKind::AgentIssued, Facts { agent: Some(agent), .. }) => (
format!("\u{201c}{}\u{201d} can now report as you", agent.name),
"A token by that name was issued for your account. Its days will be filed under your name. If this is not your machine, tell an administrator of this server.".to_string(),
),
(NotificationKind::AgentRevoked, Facts { agent: Some(agent), .. }) => (
format!("\u{201c}{}\u{201d} can no longer report as you", agent.name),
"Its token was revoked. What it already sent stays; anything it sends from now on is refused.".to_string(),
),
(NotificationKind::PrivacyChanged, Facts { privacy: Some(privacy), .. }) => (
"What this server keeps about you changed".to_string(),
format!(
"The privacy level went from {} to {}. {} It applies to what arrives from now on.",
level_name(privacy.from),
level_name(privacy.to),
crate::privacy::summary_for(privacy.to)
),
),
(kind, _) => {
tracing::warn!(id, ?kind, "a notification is missing the facts its kind needs");
("A notice from kasl-server".to_string(), "Open the web UI to read it.".to_string())
}
}
}
fn alert_words(alert: &AlertPayload) -> (String, String) {
let date = alert.subject_date.map(|date| date.to_string()).unwrap_or_default();
match alert.rule {
AlertRule::DayNotClosed => (
format!("Your day of {date} is still open here"),
format!(
"This server has had it open for {}, and your manager was told. If kasl closed the day on your machine, the close did not arrive: `kasl server push --date {date}` sends it again.",
span(alert.observed_seconds)
),
),
AlertRule::Overwork => (
format!("Your manager was told about {date}"),
format!(
"You worked {} h that day, against your norm of {} h.",
hours(alert.observed_seconds),
alert.against_seconds.map(hours).unwrap_or_else(|| "—".to_string())
),
),
AlertRule::NoAgentData => (
"Your manager was told your machines went quiet".to_string(),
format!("Nothing had arrived from any of your machines for {}.", span(alert.observed_seconds)),
),
}
}
fn level_name(level: PrivacyLevel) -> &'static str {
match level {
PrivacyLevel::Full => "full",
PrivacyLevel::Moderate => "moderate",
PrivacyLevel::Coarse => "coarse",
}
}
const PENDING_FOR_AGENT: &str = "
FROM notifications n
JOIN agents a ON a.id = $1
JOIN users u ON u.id = a.user_id
LEFT JOIN alerts al ON al.id = n.alert_id
WHERE n.user_id = a.user_id
AND n.id > greatest(a.notified_through, u.notifications_read_through)
AND n.created_at >= a.created_at
AND n.agent_id IS DISTINCT FROM a.id
AND al.resolved_at IS NULL
AND al.rule IS DISTINCT FROM 'no_agent_data'";
pub async fn pending_count(pool: &PgPool, agent_id: Uuid) -> Result<i64, ApiError> {
Ok(sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT count(*) {PENDING_FOR_AGENT}")))
.bind(agent_id)
.fetch_one(pool)
.await?)
}
const AGENT_PAGE: i64 = 20;
#[derive(Debug, Serialize)]
pub struct AgentQueue {
pub notifications: Vec<Notification>,
pub more: bool,
}
pub async fn agent_queue(State(state): State<AppState>, agent: AuthenticatedAgent) -> Result<impl IntoResponse, ApiError> {
let mut rows: Vec<Row> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
"SELECT n.id, n.kind, n.created_at, n.payload, al.resolved_at AS withdrawn_at, false AS read
{PENDING_FOR_AGENT}
ORDER BY n.id
LIMIT $2"
)))
.bind(agent.agent_id)
.bind(AGENT_PAGE + 1)
.fetch_all(&state.pool)
.await?;
let more = rows.len() as i64 > AGENT_PAGE;
rows.truncate(AGENT_PAGE as usize);
Ok(Json(AgentQueue {
notifications: rows.into_iter().map(|row| row.into_notification(&state.webhooks)).collect(),
more,
}))
}
#[derive(Debug, Deserialize)]
pub struct Through {
pub through: i64,
}
#[derive(Debug, Serialize)]
pub struct Cursor {
pub through: i64,
}
fn validate(through: &Through) -> Result<(), ApiError> {
if through.through < 0 {
return Err(ApiError::bad_request("`through` is the id of a notification, and ids start at 1"));
}
Ok(())
}
pub async fn agent_ack(State(state): State<AppState>, agent: AuthenticatedAgent, Json(through): Json<Through>) -> Result<impl IntoResponse, ApiError> {
validate(&through)?;
let moved: i64 = sqlx::query_scalar(
"UPDATE agents
SET notified_through = greatest(notified_through,
least($2, (SELECT coalesce(max(id), 0) FROM notifications WHERE user_id = agents.user_id)))
WHERE id = $1
RETURNING notified_through",
)
.bind(agent.agent_id)
.bind(through.through)
.fetch_one(&state.pool)
.await?;
Ok(Json(Cursor { through: moved }))
}
pub async fn agent_read(State(state): State<AppState>, agent: AuthenticatedAgent, Json(through): Json<Through>) -> Result<impl IntoResponse, ApiError> {
validate(&through)?;
let moved = mark_read(&state.pool, agent.user_id, through.through).await?;
Ok(Json(Cursor { through: moved }))
}
async fn mark_read(pool: &PgPool, user_id: Uuid, through: i64) -> Result<i64, ApiError> {
Ok(sqlx::query_scalar(
"UPDATE users
SET notifications_read_through = greatest(notifications_read_through,
least($2, (SELECT coalesce(max(id), 0) FROM notifications WHERE user_id = users.id)))
WHERE id = $1
RETURNING notifications_read_through",
)
.bind(user_id)
.bind(through)
.fetch_one(pool)
.await?)
}
const INBOX_PAGE: i64 = 100;
#[derive(Debug, Serialize)]
pub struct Inbox {
pub notifications: Vec<Notification>,
pub unread: i64,
pub read_through: i64,
}
pub async fn inbox(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
let read_through: i64 = sqlx::query_scalar("SELECT notifications_read_through FROM users WHERE id = $1")
.bind(user.user_id)
.fetch_one(&state.pool)
.await?;
let rows: Vec<Row> = sqlx::query_as(
"SELECT n.id, n.kind, n.created_at, n.payload, al.resolved_at AS withdrawn_at, n.id <= $2 AS read
FROM notifications n
LEFT JOIN alerts al ON al.id = n.alert_id
WHERE n.user_id = $1
ORDER BY n.id DESC
LIMIT $3",
)
.bind(user.user_id)
.bind(read_through)
.bind(INBOX_PAGE)
.fetch_all(&state.pool)
.await?;
let unread: i64 = sqlx::query_scalar(
"SELECT count(*) FROM notifications n LEFT JOIN alerts al ON al.id = n.alert_id
WHERE n.user_id = $1 AND n.id > $2 AND al.resolved_at IS NULL",
)
.bind(user.user_id)
.bind(read_through)
.fetch_one(&state.pool)
.await?;
Ok(Json(Inbox {
notifications: rows.into_iter().map(|row| row.into_notification(&state.webhooks)).collect(),
unread,
read_through,
}))
}
pub async fn read(State(state): State<AppState>, user: CurrentUser, Json(through): Json<Through>) -> Result<impl IntoResponse, ApiError> {
validate(&through)?;
let moved = mark_read(&state.pool, user.user_id, through.through).await?;
Ok((StatusCode::OK, Json(Cursor { through: moved })))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
fn alert(rule: AlertRule, observed: i64, against: Option<i64>, date: Option<&str>) -> Facts {
Facts {
alert: Some(AlertPayload {
id: Uuid::nil(),
rule,
observed_seconds: observed,
against_seconds: against,
subject_date: date.map(|date| date.parse::<NaiveDate>().unwrap()),
fired_at: "2026-09-22T10:00:00Z".parse().unwrap(),
}),
..Facts::default()
}
}
#[test]
fn an_open_day_says_how_to_send_it_again() {
let (title, body) = words(
1,
NotificationKind::AlertRaised,
&alert(AlertRule::DayNotClosed, 17 * 3600, Some(16 * 3600), Some("2026-09-22")),
);
assert_eq!(title, "Your day of 2026-09-22 is still open here");
assert!(body.contains("17 h"), "{body}");
assert!(body.contains("kasl server push --date 2026-09-22"), "{body}");
}
#[test]
fn a_long_day_names_both_figures() {
let (title, body) = words(
1,
NotificationKind::AlertRaised,
&alert(AlertRule::Overwork, 12 * 3600 + 36 * 60, Some(8 * 3600), Some("2026-09-17")),
);
assert_eq!(title, "Your manager was told about 2026-09-17");
assert_eq!(body, "You worked 12.6 h that day, against your norm of 8 h.");
}
#[test]
fn silence_is_told_in_its_own_unit() {
let (_, body) = words(
1,
NotificationKind::AlertRaised,
&alert(AlertRule::NoAgentData, 13 * 3600, Some(12 * 3600), None),
);
assert_eq!(body, "Nothing had arrived from any of your machines for 13 h.");
}
#[test]
fn a_machine_is_named_as_it_was() {
let facts = Facts {
agent: Some(AgentFact {
id: Uuid::nil(),
name: "laptop".to_string(),
}),
..Facts::default()
};
assert_eq!(
words(1, NotificationKind::AgentIssued, &facts).0,
"\u{201c}laptop\u{201d} can now report as you"
);
assert_eq!(
words(1, NotificationKind::AgentRevoked, &facts).0,
"\u{201c}laptop\u{201d} can no longer report as you"
);
}
#[test]
fn a_privacy_change_names_both_levels_and_what_is_kept_now() {
let facts = Facts {
privacy: Some(PrivacyFact {
from: PrivacyLevel::Full,
to: PrivacyLevel::Coarse,
}),
..Facts::default()
};
let (_, body) = words(1, NotificationKind::PrivacyChanged, &facts);
assert!(body.contains("from full to coarse"), "{body}");
assert!(
body.contains(crate::privacy::summary_for(PrivacyLevel::Coarse)),
"the new level is described: {body}"
);
}
#[test]
fn a_notice_without_its_facts_still_says_something() {
let (title, body) = words(7, NotificationKind::AgentIssued, &Facts::default());
assert!(!title.is_empty() && !body.is_empty());
}
#[test]
fn the_wire_names_are_the_contract() {
for (kind, name) in [
(NotificationKind::AlertRaised, "alert.raised"),
(NotificationKind::AgentIssued, "agent.issued"),
(NotificationKind::AgentRevoked, "agent.revoked"),
(NotificationKind::PrivacyChanged, "privacy.changed"),
] {
assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::json!(name));
}
}
#[test]
fn only_the_facts_of_the_kind_travel() {
let json = serde_json::to_value(alert(AlertRule::Overwork, 1, None, None)).unwrap();
let keys: Vec<&String> = json.as_object().unwrap().keys().collect();
assert_eq!(keys, ["alert"]);
}
}