Skip to main content

kasl_server/
alerts.rs

1//! Alerts: what the server noticed now, and what somebody did about it.
2//!
3//! Every view before this one waits to be opened. The signals say where to
4//! look, which is a real advance over a table of totals - and they still say
5//! it on a page, in whole weeks, to whoever happens to visit. The case this
6//! milestone exists for is the one where waiting is the failure: an agent that
7//! died on Monday morning, a day left open over a weekend, somebody's eleventh
8//! hour. None of those improve by being discovered on Thursday.
9//!
10//! **An alert is a stored observation, and a signal is not stored at all.**
11//! That difference is the whole design, and it is not an inconsistency. A
12//! signal is a function of the days already in the database, so a table of
13//! signals would be a second copy of a derived fact (ADR 0016). An alert
14//! carries two things no day can produce:
15//!
16//! * **when the condition began.** The absence of rows has no timestamp. A
17//!   recomputation can say a thing is true; only a row says since when.
18//! * **what a person decided about it.** A manager who looked and concluded it
19//!   was a holiday needs it to stop shouting. An alert that cannot be answered
20//!   trains people to ignore the whole column, which is the lesson `unknown`
21//!   taught in ADR 0014.
22//!
23//! So the rule is still computed from the days, every sweep. What is written
24//! down is the event of having noticed - and the sweep **reconciles**, rather
25//! than inserting: it builds what ought to be open right now, compares it with
26//! what is open, and moves the difference. That is why a week of silence is
27//! one row and not seven, and why an agent coming back closes its own alert
28//! without anybody clicking anything.
29//!
30//! The three rules, and why each one can be stated honestly only now:
31//!
32//! * `no_agent_data` - nothing has arrived from any of this person's machines
33//!   for longer than the installation's threshold. In hours, about *now*,
34//!   which is what makes it a different object from the `no_data` signal: that
35//!   one measures a person's own weekly rhythm and cannot speak before the
36//!   week is complete.
37//! * `overwork` - a finished day ran past what that person owed it, by a
38//!   share of their own norm. Before the production calendar this could only
39//!   have been a number of hours invented here, which is this product
40//!   asserting what a working day is on someone else's team - exactly what
41//!   ADR 0016 refused to do. With a norm and a rate it is arithmetic
42//!   (ADR 0017).
43//! * `day_not_closed` - a day is still open long after any day plausibly runs.
44//!   Usually kasl left running overnight, and the day it will eventually
45//!   produce is wrong in a way that quietly poisons a week's total.
46//!
47//! Delivery here is in-app, and deliberately: a webhook into a chat at 3 a.m.
48//! is a different product decision with its own milestone (v0.23), and the row
49//! this module writes is precisely what that one will ship outward. Delivery
50//! gets added to the record; it does not replace it.
51
52use axum::{
53    Json,
54    extract::{Path, Query, State},
55    http::StatusCode,
56    response::IntoResponse,
57};
58use chrono::{DateTime, NaiveDate, TimeDelta, Utc};
59use rust_decimal::{Decimal, prelude::ToPrimitive};
60use serde::{Deserialize, Serialize};
61use sqlx::PgPool;
62use uuid::Uuid;
63
64use crate::{
65    admin::{VISIBLE_USERS, require_manager_or_admin},
66    app::AppState,
67    audit,
68    calendar::{Calendar, WorkdayKind},
69    error::ApiError,
70    login::CurrentUser,
71    model::UserRole,
72};
73
74/// Seconds in an hour.
75const SECONDS_PER_HOUR: i64 = 3600;
76
77/// Which rule noticed something.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)]
79#[sqlx(type_name = "alert_rule", rename_all = "snake_case")]
80#[serde(rename_all = "snake_case")]
81pub enum AlertRule {
82    /// Nothing has arrived from this person's agents for longer than the
83    /// installation allows.
84    NoAgentData,
85    /// A finished day ran well past that person's own norm for it.
86    Overwork,
87    /// A day is still open long after any day plausibly runs.
88    DayNotClosed,
89}
90
91impl AlertRule {
92    /// Which alerts a feed shows first. Lower sorts earlier.
93    ///
94    /// Silence outranks the rest for the reason it does among the signals: an
95    /// agent that stopped reporting makes every other number about that person
96    /// untrustworthy, including the two below.
97    fn severity(self) -> u8 {
98        match self {
99            Self::NoAgentData => 0,
100            Self::DayNotClosed => 1,
101            Self::Overwork => 2,
102        }
103    }
104}
105
106/// Where an alert stands.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
108#[sqlx(type_name = "alert_state", rename_all = "snake_case")]
109#[serde(rename_all = "snake_case")]
110pub enum AlertState {
111    /// Still true, and still unattended.
112    Open,
113    /// A person looked and decided it needs no action. The condition may well
114    /// still hold - the alert is answered, not gone.
115    Acknowledged,
116    /// The condition stopped being true on its own, and nobody had to do
117    /// anything. Distinct from `acknowledged` on purpose: how much of what the
118    /// server shouted about was real is the one question worth asking of this
119    /// table later, and a single "closed" flag would throw the answer away.
120    Resolved,
121}
122
123/// The thresholds this installation speaks at.
124///
125/// Settings, unlike the signal thresholds, which ADR 0016 deliberately fixed
126/// in code. The distinction is who is interrupted. A signal is read by whoever
127/// opened the page, and a sensitivity slider there is a knob on an opinion. An
128/// alert interrupts somebody, and how much silence is worth interrupting over
129/// genuinely differs between a team in one timezone and a team across four.
130///
131/// What is not configurable is the set of rules - that stays a choice, not an
132/// operator's to invent.
133#[derive(Debug, Clone, Copy, Serialize, Deserialize, sqlx::FromRow)]
134pub struct Thresholds {
135    /// Hours of silence from every one of a person's agents before it is an
136    /// alert.
137    pub alert_silence_hours: i32,
138    /// How far past their own norm a finished day has to run, as a share of
139    /// that norm. A share rather than a number of hours, so it means the same
140    /// thing for somebody on half time.
141    pub alert_overwork_factor: Decimal,
142    /// Hours a day may stay open before the server says so.
143    pub alert_open_day_hours: i32,
144}
145
146impl Thresholds {
147    pub async fn load(pool: &PgPool) -> Result<Self, ApiError> {
148        Ok(
149            sqlx::query_as("SELECT alert_silence_hours, alert_overwork_factor, alert_open_day_hours FROM settings WHERE singleton")
150                .fetch_one(pool)
151                .await?,
152        )
153    }
154
155    fn silence_seconds(&self) -> i64 {
156        i64::from(self.alert_silence_hours) * SECONDS_PER_HOUR
157    }
158
159    fn open_day_seconds(&self) -> i64 {
160        i64::from(self.alert_open_day_hours) * SECONDS_PER_HOUR
161    }
162}
163
164/// What the rules are evaluated against, for one person, at one moment.
165///
166/// A plain struct assembled by the sweep's queries rather than the rows
167/// themselves, so every rule below is a function of values and can be tested
168/// without a database. The arithmetic is the part most worth testing, and
169/// logic inside a query can only be tested through one - the same reason the
170/// signals do their statistics in Rust.
171#[derive(Debug, Clone)]
172pub struct Observation {
173    pub user_id: Uuid,
174    /// When anything last arrived from any of this person's live agents: the
175    /// later of a request that used the token and a pulse. `None` for somebody
176    /// whose agent has never said anything at all.
177    ///
178    /// **Both, and the freshest wins.** `agents.last_seen_at` alone is what a
179    /// first version of this rule read, and a live run against the demo showed
180    /// what that costs: nine of twelve people flagged as silent, six of them
181    /// having pulsed seconds earlier. The two stamps answer different
182    /// questions - "the token was used" and "kasl is watching a person work"
183    /// (ADR 0014) - and an agent can go a long time doing only the second: a
184    /// machine whose employee is on holiday pulses `idle` all week and uploads
185    /// nothing at all. Reading one of them is how a rule about *silence*
186    /// alerts on somebody who is plainly talking.
187    pub last_seen_at: Option<DateTime<Utc>>,
188    /// Whether this person has any live agent token. Somebody with none is not
189    /// silent - they were never asked to speak.
190    pub has_live_agent: bool,
191    /// The finished day with the most seconds worked in the window the sweep
192    /// looks at, as `(date, worked_seconds, kind)`.
193    pub longest_finished_day: Option<(NaiveDate, i64, WorkdayKind)>,
194    /// A day still open, as `(date, started_at)`. At most one: the schema
195    /// allows a person one day per date, and a second open day would be a much
196    /// louder problem than this rule.
197    pub open_day: Option<(NaiveDate, DateTime<Utc>)>,
198    /// This person's share of a full day.
199    pub work_rate: Decimal,
200}
201
202/// A condition the sweep found true: the rule, and the figures it fired on.
203///
204/// Not an alert yet. Whether it becomes a row depends on what is already open,
205/// which is the sweep's business rather than the rule's.
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct Finding {
208    pub user_id: Uuid,
209    pub rule: AlertRule,
210    /// What was measured, in seconds.
211    pub observed_seconds: i64,
212    /// What it was measured against, in seconds, where there is a second
213    /// figure to show. Both travel to the screen because a percentage cannot
214    /// be un-divided: "11 h against a norm of 8" and "138%" are not the same
215    /// sentence, and only the first lets a reader disagree with it (ADR 0017).
216    pub against_seconds: Option<i64>,
217    /// The date it is about, for the rules that are about a day.
218    pub subject_date: Option<NaiveDate>,
219}
220
221/// Every rule, applied to one person.
222///
223/// The whole of what this server is willing to interrupt somebody about, in
224/// one function, so the set can be read in one place and a fourth rule has an
225/// obvious home.
226pub fn findings(observation: &Observation, calendar: &Calendar, standard_hours: Decimal, thresholds: &Thresholds, now: DateTime<Utc>) -> Vec<Finding> {
227    let mut found = Vec::new();
228
229    if let Some(silent_for) = silent_for(observation, thresholds, now) {
230        found.push(Finding {
231            user_id: observation.user_id,
232            rule: AlertRule::NoAgentData,
233            observed_seconds: silent_for,
234            against_seconds: Some(thresholds.silence_seconds()),
235            subject_date: None,
236        });
237    }
238
239    if let Some((date, worked, norm)) = overworked(observation, calendar, standard_hours, thresholds) {
240        found.push(Finding {
241            user_id: observation.user_id,
242            rule: AlertRule::Overwork,
243            observed_seconds: worked,
244            against_seconds: Some(norm),
245            subject_date: Some(date),
246        });
247    }
248
249    if let Some((date, open_for)) = open_too_long(observation, thresholds, now) {
250        found.push(Finding {
251            user_id: observation.user_id,
252            rule: AlertRule::DayNotClosed,
253            observed_seconds: open_for,
254            against_seconds: Some(thresholds.open_day_seconds()),
255            subject_date: Some(date),
256        });
257    }
258
259    found
260}
261
262/// How long this person's agents have been silent, when that is longer than
263/// the installation allows.
264///
265/// Somebody with no live agent is not silent - nothing was ever asked of them,
266/// and an installation that has not finished handing out tokens would otherwise
267/// alert about every account on its first day. The dashboard already says
268/// "no agents" in words, which is a different and more useful sentence.
269///
270/// Somebody who has an agent and has never used it is silent from the moment
271/// the token was issued - but this rule cannot see that date, and inventing
272/// one would be worse than the `no_data` signal's honest silence. `None`, and
273/// the team table's "never reported" keeps that case.
274fn silent_for(observation: &Observation, thresholds: &Thresholds, now: DateTime<Utc>) -> Option<i64> {
275    if !observation.has_live_agent {
276        return None;
277    }
278    let last_seen = observation.last_seen_at?;
279    let silent = (now - last_seen).num_seconds();
280    (silent >= thresholds.silence_seconds()).then_some(silent)
281}
282
283/// The finished day that ran furthest past its norm, when one did, as
284/// `(date, worked, norm)`.
285///
286/// Against that person's own norm for that date, so a short day before a
287/// holiday is a lower bar and a half-time employee is measured against half a
288/// day. An installation-wide "more than ten hours" would call a part-timer's
289/// doubled day ordinary and never mention it.
290///
291/// A day of leave or illness is skipped rather than compared: its norm is
292/// zero, so any work at all on it would exceed the norm by an infinite share,
293/// and "you worked on your holiday" is a fact this product has no business
294/// raising with a manager. That is between the employee and their own screen.
295fn overworked(observation: &Observation, calendar: &Calendar, standard_hours: Decimal, thresholds: &Thresholds) -> Option<(NaiveDate, i64, i64)> {
296    let (date, worked, kind) = observation.longest_finished_day?;
297    if !kind.owes_the_norm() {
298        return None;
299    }
300
301    let norm = calendar.norm_seconds(date, standard_hours, observation.work_rate);
302    // A date the calendar says is not worked - a weekend, a holiday - has a
303    // norm of zero and no factor of it is anything. Somebody working a
304    // Saturday is worth noticing, and this is not the rule that notices it:
305    // there is no norm to be a multiple of, so a threshold here would be a
306    // number invented after all.
307    if norm <= 0 {
308        return None;
309    }
310
311    let threshold = (Decimal::from(norm) * thresholds.alert_overwork_factor).round().to_i64()?;
312    (worked >= threshold).then_some((date, worked, norm))
313}
314
315/// How long a day has been open, when that is longer than any day runs.
316///
317/// Measured from the day's start on the wall clock, not from its norm: an open
318/// day has no total yet - there is nothing to compare with a norm - and what
319/// is wrong with it is simply elapsed time.
320fn open_too_long(observation: &Observation, thresholds: &Thresholds, now: DateTime<Utc>) -> Option<(NaiveDate, i64)> {
321    let (date, started_at) = observation.open_day?;
322    let open_for = (now - started_at).num_seconds();
323    (open_for >= thresholds.open_day_seconds()).then_some((date, open_for))
324}
325
326// The sweep -------------------------------------------------------------------
327
328/// How far back the sweep looks for a day to judge.
329///
330/// A fortnight: long enough that a day filed late is still examined, short
331/// enough that the sweep is not re-reading a quarter every few minutes. An
332/// alert about a day from last month would be archaeology, not an alert.
333const LOOKBACK_DAYS: i64 = 14;
334
335/// What one sweep did.
336#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
337pub struct Swept {
338    /// Conditions that were not open before: new rows.
339    pub raised: u64,
340    /// Open alerts whose condition stopped being true: closed by the server.
341    pub resolved: u64,
342    /// Conditions that held and produced no new row: already open, or already
343    /// answered by somebody. Reported because zero raised and zero resolved is
344    /// the same output as a sweep that found nothing at all, and only one of
345    /// those is quiet good news.
346    ///
347    /// The two cases are counted together on purpose - this is a log line, and
348    /// what it has to say is "the sweep ran and the world was as expected".
349    /// Which alerts are open and which were answered is a question for the
350    /// feed, where the rows are.
351    pub unchanged: u64,
352}
353
354/// Looks at everyone, and moves the alerts to match what is true now.
355///
356/// Reconciliation rather than insertion, which is what keeps a fortnight of
357/// silence one row instead of a row per sweep. Three outcomes per person and
358/// rule: the condition holds and nothing is open (raise), it holds and
359/// something is open (leave it - notably **without** rewriting its figures, so
360/// "quiet since Monday, 9 h" does not silently become "quiet since Monday,
361/// 400 h"; the sentence an alert makes is the one it made when it fired), or
362/// nothing holds and something is open (resolve).
363///
364/// An acknowledged alert is not touched by any of this. A person answered it,
365/// and the server re-raising it on the next sweep is the exact behaviour that
366/// makes people stop reading alerts. It comes back only if the condition
367/// resolves and later becomes true again, which is a genuinely new event.
368pub async fn sweep(pool: &PgPool, now: DateTime<Utc>) -> Result<Swept, ApiError> {
369    let thresholds = Thresholds::load(pool).await?;
370    let standard_hours: Decimal = sqlx::query_scalar("SELECT standard_hours FROM settings WHERE singleton")
371        .fetch_one(pool)
372        .await?;
373
374    let today = now.date_naive();
375    let from = today - TimeDelta::days(LOOKBACK_DAYS);
376    let calendar = Calendar::load(pool, from, today).await?;
377
378    let observations = observe(pool, from, today).await?;
379
380    // What a person has already answered. Not expressible as an `ON CONFLICT`
381    // clause: the unique index is on the *open* rows alone - deliberately, so
382    // a silence in March does not block a silence in July - so an acknowledged
383    // row conflicts with nothing and an insert would sail straight past it.
384    //
385    // An acknowledgement stands until its condition resolves. The alternative
386    // was found by a test rather than by reading: the server re-raised what a
387    // manager had just dismissed on the very next sweep, five minutes later,
388    // which is precisely the behaviour that makes people stop reading alerts.
389    // `resolved_at IS NULL` is what makes the suppression last exactly as long
390    // as the condition did: once a sweep has seen the condition go away, the
391    // stamp is set and this row stops standing in the way of the next one.
392    let acknowledged: Vec<(Uuid, AlertRule)> = sqlx::query_as("SELECT user_id, rule FROM alerts WHERE state = 'acknowledged' AND resolved_at IS NULL")
393        .fetch_all(pool)
394        .await?;
395
396    // Computed once and used by both halves of the reconciliation. Running
397    // the rules twice would be cheap and would also be two answers to "what is
398    // true now" that nothing forces to agree - and a sweep whose two halves
399    // disagreed would raise a row and resolve it in the same pass, forever.
400    let found: Vec<Finding> = observations
401        .iter()
402        .flat_map(|observation| findings(observation, &calendar, standard_hours, &thresholds, now))
403        .collect();
404
405    let mut swept = Swept::default();
406    for finding in &found {
407        if acknowledged.contains(&(finding.user_id, finding.rule)) {
408            swept.unchanged += 1;
409            continue;
410        }
411        // `ON CONFLICT DO NOTHING` against the partial unique index: the
412        // index is what makes "one open alert per person per rule" true
413        // even if two sweeps overlap, and this is how the loser of that
414        // race finds out without failing.
415        let inserted = sqlx::query(
416            "INSERT INTO alerts (user_id, rule, observed_seconds, against_seconds, subject_date, fired_at)
417                 VALUES ($1, $2, $3, $4, $5, $6)
418                 ON CONFLICT (user_id, rule) WHERE state = 'open' DO NOTHING",
419        )
420        .bind(finding.user_id)
421        .bind(finding.rule)
422        .bind(finding.observed_seconds)
423        .bind(finding.against_seconds)
424        .bind(finding.subject_date)
425        .bind(now)
426        .execute(pool)
427        .await?;
428
429        if inserted.rows_affected() > 0 {
430            swept.raised += 1;
431            tracing::info!(user = %finding.user_id, rule = ?finding.rule, "raised an alert");
432        } else {
433            swept.unchanged += 1;
434        }
435    }
436
437    let still_true: Vec<(Uuid, AlertRule)> = found.iter().map(|finding| (finding.user_id, finding.rule)).collect();
438
439    // A person the sweep did not observe at all - an account deactivated since
440    // the alert was raised - has no findings, so everything open about them
441    // resolves. That is the right answer: the condition is no longer being
442    // observed to hold, and an alert nobody can act on any more is noise.
443    //
444    // The acknowledged rows are swept too, and that is not a detail. An
445    // acknowledgement suppresses the next raise for as long as the condition
446    // lasts; if it never ended, the suppression would be permanent, and a
447    // person quiet again next month would go unmentioned because somebody
448    // dismissed last month's silence. Ending it here is what makes the
449    // return of a condition a genuinely new event rather than a lost one.
450    let standing: Vec<(Uuid, Uuid, AlertRule)> = sqlx::query_as("SELECT id, user_id, rule FROM alerts WHERE state IN ('open', 'acknowledged')")
451        .fetch_all(pool)
452        .await?;
453
454    for (id, user_id, rule) in standing {
455        if still_true.contains(&(user_id, rule)) {
456            continue;
457        }
458        let closed = sqlx::query("UPDATE alerts SET state = 'resolved', resolved_at = $2 WHERE id = $1 AND state = 'open'")
459            .bind(id)
460            .bind(now)
461            .execute(pool)
462            .await?;
463
464        if closed.rows_affected() > 0 {
465            swept.resolved += 1;
466            tracing::info!(user = %user_id, rule = ?rule, "an alert resolved itself");
467            continue;
468        }
469
470        // An acknowledged one keeps its state - it was answered, and rewriting
471        // that to `resolved` would erase the fact that a person looked. Only
472        // the stamp is added, which is what a later sweep reads to know the
473        // suppression is spent.
474        sqlx::query("UPDATE alerts SET resolved_at = $2 WHERE id = $1 AND state = 'acknowledged' AND resolved_at IS NULL")
475            .bind(id)
476            .bind(now)
477            .execute(pool)
478            .await?;
479    }
480
481    Ok(swept)
482}
483
484/// Gathers what the rules need, for everyone who could have an alert.
485///
486/// Everyone active, not only the people some particular manager may see: the
487/// sweep writes the record, and who is allowed to *read* a given row is
488/// decided when the feed is asked for. Filtering here would make an alert's
489/// existence depend on who happened to trigger the sweep.
490async fn observe(pool: &PgPool, from: NaiveDate, to: NaiveDate) -> Result<Vec<Observation>, ApiError> {
491    let rows: Vec<ObservationRow> = sqlx::query_as(
492        "SELECT u.id AS user_id,
493                u.work_rate,
494                (SELECT max(greatest(a.last_seen_at, a.heartbeat_received_at))
495                 FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS last_seen_at,
496                EXISTS (SELECT 1 FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS has_live_agent,
497                longest.date        AS longest_date,
498                longest.worked      AS longest_worked,
499                longest.kind        AS longest_kind,
500                open.date           AS open_date,
501                open.started_at     AS open_started_at
502         FROM users u
503         -- The finished day in the window with the most seconds worked. One
504         -- day per person and not all of them: the rule fires on the worst,
505         -- and a manager does not need eleven rows to be told about a week of
506         -- eleven-hour days. The next one surfaces once this is answered.
507         LEFT JOIN LATERAL (
508             SELECT w.date,
509                    w.kind,
510                    EXTRACT(EPOCH FROM (w.ended_at - w.started_at))::bigint
511                        - COALESCE((SELECT sum(EXTRACT(EPOCH FROM (p.ended_at - p.started_at)))::bigint
512                                    FROM pauses p WHERE p.workday_id = w.id AND p.ended_at IS NOT NULL), 0) AS worked
513             FROM workdays w
514             WHERE w.user_id = u.id AND w.date BETWEEN $1 AND $2 AND w.ended_at IS NOT NULL
515             ORDER BY worked DESC
516             LIMIT 1
517         ) AS longest ON true
518         -- The open day, if there is one. Ordered so that if a schema ever let
519         -- a person have two, this picks the one that has been open longest -
520         -- the one worth saying something about.
521         LEFT JOIN LATERAL (
522             SELECT w.date, w.started_at
523             FROM workdays w
524             WHERE w.user_id = u.id AND w.ended_at IS NULL
525             ORDER BY w.started_at
526             LIMIT 1
527         ) AS open ON true
528         WHERE u.active",
529    )
530    .bind(from)
531    .bind(to)
532    .fetch_all(pool)
533    .await?;
534
535    Ok(rows.into_iter().map(Observation::from).collect())
536}
537
538/// One person as the sweep's query returns them.
539#[derive(Debug, sqlx::FromRow)]
540struct ObservationRow {
541    user_id: Uuid,
542    work_rate: Decimal,
543    last_seen_at: Option<DateTime<Utc>>,
544    has_live_agent: bool,
545    longest_date: Option<NaiveDate>,
546    longest_worked: Option<i64>,
547    longest_kind: Option<WorkdayKind>,
548    open_date: Option<NaiveDate>,
549    open_started_at: Option<DateTime<Utc>>,
550}
551
552impl From<ObservationRow> for Observation {
553    fn from(row: ObservationRow) -> Self {
554        Self {
555            user_id: row.user_id,
556            last_seen_at: row.last_seen_at,
557            has_live_agent: row.has_live_agent,
558            // All three or none: they come from one `LEFT JOIN LATERAL`, so a
559            // partial tuple would mean the query changed shape underneath this.
560            longest_finished_day: match (row.longest_date, row.longest_worked, row.longest_kind) {
561                (Some(date), Some(worked), Some(kind)) => Some((date, worked.max(0), kind)),
562                _ => None,
563            },
564            open_day: row.open_date.zip(row.open_started_at),
565            work_rate: row.work_rate,
566        }
567    }
568}
569
570// The API ---------------------------------------------------------------------
571
572/// One alert, as the feed answers it.
573#[derive(Debug, Serialize, sqlx::FromRow)]
574pub struct Alert {
575    pub id: Uuid,
576    pub user_id: Uuid,
577    pub display_name: String,
578    pub department: Option<String>,
579    pub rule: AlertRule,
580    pub state: AlertState,
581    pub fired_at: DateTime<Utc>,
582    pub resolved_at: Option<DateTime<Utc>>,
583    pub acknowledged_at: Option<DateTime<Utc>>,
584    /// Who answered it, by name, so the feed can say so without a second
585    /// request. Null on one the server resolved by itself - which is how "it
586    /// went away" is told from "somebody decided it was fine".
587    pub acknowledged_by: Option<String>,
588    pub observed_seconds: i64,
589    pub against_seconds: Option<i64>,
590    pub subject_date: Option<NaiveDate>,
591}
592
593/// The feed, and what the reader can say about it.
594#[derive(Debug, Serialize)]
595pub struct Feed {
596    pub alerts: Vec<Alert>,
597    /// How many of them are open. The screen's badge, and not `alerts.len()`:
598    /// a feed showing the answered ones too would otherwise count them.
599    pub open: i64,
600    /// People the sweep is watching. `0 of 12` is a different message from
601    /// "nothing wrong", and a screen that cannot tell them apart shows the
602    /// reassuring one - the same rule the signals band follows.
603    pub people: i64,
604    /// The thresholds these were raised at, so the screen can say what "too
605    /// long" meant without a second request, and an administrator can see the
606    /// figure they are about to change.
607    pub thresholds: Thresholds,
608}
609
610/// What a caller may narrow the feed to.
611#[derive(Debug, Deserialize)]
612pub struct FeedQuery {
613    /// `open` (the default), `all`, or one state by name. Answered rather than
614    /// always returning everything: a manager opening the dashboard wants what
615    /// is unattended, and the history is a deliberate second click.
616    #[serde(default)]
617    pub state: Option<String>,
618}
619
620/// Answers the alerts about everyone the reader may see.
621pub async fn feed(State(state): State<AppState>, user: CurrentUser, Query(query): Query<FeedQuery>) -> Result<impl IntoResponse, ApiError> {
622    require_manager_or_admin(&user)?;
623
624    let wanted = query.state.as_deref().unwrap_or("open");
625    let filter = match wanted {
626        "open" => "AND al.state = 'open'",
627        "all" => "",
628        "acknowledged" => "AND al.state = 'acknowledged'",
629        "resolved" => "AND al.state = 'resolved'",
630        other => {
631            return Err(ApiError::bad_request(format!(
632                "unknown state `{other}`: expected open, acknowledged, resolved or all"
633            )));
634        }
635    };
636
637    // `AssertSqlSafe` because the only interpolations are `VISIBLE_USERS`, a
638    // constant in `admin`, and `filter`, matched from a closed set just above;
639    // everything from the request is still bound.
640    let alerts: Vec<Alert> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
641        "SELECT al.id, al.user_id, u.display_name, d.name AS department,
642                al.rule, al.state, al.fired_at, al.resolved_at, al.acknowledged_at,
643                ack.display_name AS acknowledged_by,
644                al.observed_seconds, al.against_seconds, al.subject_date
645         FROM alerts al
646         JOIN users u ON u.id = al.user_id
647         LEFT JOIN departments d ON d.id = u.department_id
648         LEFT JOIN users ack ON ack.id = al.acknowledged_by
649         WHERE {VISIBLE_USERS} {filter}
650         ORDER BY al.fired_at DESC
651         LIMIT 200"
652    )))
653    .bind(user.role == UserRole::Admin)
654    .bind(user.user_id)
655    .fetch_all(&state.pool)
656    .await?;
657
658    let open: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!(
659        "SELECT count(*) FROM alerts al JOIN users u ON u.id = al.user_id WHERE {VISIBLE_USERS} AND al.state = 'open'"
660    )))
661    .bind(user.role == UserRole::Admin)
662    .bind(user.user_id)
663    .fetch_one(&state.pool)
664    .await?;
665
666    let people: i64 = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT count(*) FROM users u WHERE {VISIBLE_USERS} AND u.active")))
667        .bind(user.role == UserRole::Admin)
668        .bind(user.user_id)
669        .fetch_one(&state.pool)
670        .await?;
671
672    // Worst first within the same moment, newest first across moments: a feed
673    // read top-down should open with the silence that arrived this morning.
674    let mut alerts = alerts;
675    alerts.sort_by_key(|alert| (alert.rule.severity(), std::cmp::Reverse(alert.fired_at)));
676
677    let thresholds = Thresholds::load(&state.pool).await?;
678
679    Ok(Json(Feed {
680        alerts,
681        open,
682        people,
683        thresholds,
684    }))
685}
686
687/// Answers an alert: a person looked, and it needs no action.
688///
689/// Not a delete. The row stays, with who answered it and when, because "this
690/// was raised and a human decided it was fine" is the only evidence that the
691/// thresholds are set somewhere sensible - and a table whose rows disappear
692/// when they are handled can never be asked how it is doing.
693pub async fn acknowledge(State(state): State<AppState>, user: CurrentUser, Path(id): Path<Uuid>) -> Result<impl IntoResponse, ApiError> {
694    require_manager_or_admin(&user)?;
695
696    // The visibility rule decides this too. Without it a manager could answer
697    // an alert about somebody in another department - harmless in itself, and
698    // it would tell them that person exists, which ADR 0009 says it must not.
699    let owned: Option<(Uuid, AlertRule)> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
700        "SELECT al.user_id, al.rule FROM alerts al JOIN users u ON u.id = al.user_id WHERE al.id = $3 AND {VISIBLE_USERS}"
701    )))
702    .bind(user.role == UserRole::Admin)
703    .bind(user.user_id)
704    .bind(id)
705    .fetch_optional(&state.pool)
706    .await?;
707
708    // The same 404 for "not yours" as for "no such alert", for the reason the
709    // drill-down gives one: a manager probing ids must not be able to tell an
710    // employee in another department from one who does not exist.
711    let Some((subject, rule)) = owned else {
712        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such alert"));
713    };
714
715    let updated = sqlx::query(
716        "UPDATE alerts SET state = 'acknowledged', acknowledged_at = now(), acknowledged_by = $2
717         WHERE id = $1 AND state = 'open'",
718    )
719    .bind(id)
720    .bind(user.user_id)
721    .execute(&state.pool)
722    .await?;
723
724    if updated.rows_affected() == 0 {
725        // Already answered, or resolved itself between the page loading and
726        // the click. Said plainly rather than silently succeeding: the screen
727        // is about to show a state the reader did not choose.
728        return Err(ApiError::new(StatusCode::CONFLICT, "that alert is no longer open"));
729    }
730
731    // The target is the person the alert is about, not the alert's own id:
732    // the question anybody brings to this log is "what happened to this
733    // employee", and an id nobody can resolve afterwards answers nothing.
734    audit::Entry::new(audit::action::ALERT_ACKNOWLEDGED)
735        .by(user.user_id)
736        .by_email(&user.email)
737        .on(subject)
738        .with(serde_json::json!({ "alert_id": id, "rule": rule }))
739        .record(&state.pool)
740        .await;
741
742    Ok((StatusCode::OK, Json(serde_json::json!({ "id": id, "state": AlertState::Acknowledged }))))
743}
744
745/// The thresholds being changed.
746#[derive(Debug, Deserialize)]
747pub struct ThresholdsInput {
748    pub alert_silence_hours: i32,
749    pub alert_overwork_factor: Decimal,
750    pub alert_open_day_hours: i32,
751}
752
753/// Sets what this installation is willing to be interrupted about.
754///
755/// All three at once rather than one route each: they are read together on
756/// every sweep and shown together on one form, and three routes would let a
757/// screen save two of them.
758pub async fn put_thresholds(State(state): State<AppState>, user: CurrentUser, Json(input): Json<ThresholdsInput>) -> Result<impl IntoResponse, ApiError> {
759    user.require_admin()?;
760
761    // Checked here as well as in the database. The constraint is what makes it
762    // true; this is what makes the refusal a sentence rather than a Postgres
763    // error code reaching the screen.
764    if input.alert_silence_hours <= 0 || input.alert_silence_hours > 720 {
765        return Err(ApiError::bad_request("silence is measured in whole hours, from 1 to 720"));
766    }
767    if input.alert_overwork_factor <= Decimal::ONE || input.alert_overwork_factor > Decimal::from(5) {
768        return Err(ApiError::bad_request("overwork is a share of the norm greater than 1 and at most 5"));
769    }
770    if input.alert_open_day_hours <= 0 || input.alert_open_day_hours > 168 {
771        return Err(ApiError::bad_request("an open day is measured in whole hours, from 1 to 168"));
772    }
773
774    let previous = Thresholds::load(&state.pool).await?;
775
776    sqlx::query("UPDATE settings SET alert_silence_hours = $1, alert_overwork_factor = $2, alert_open_day_hours = $3 WHERE singleton")
777        .bind(input.alert_silence_hours)
778        .bind(input.alert_overwork_factor)
779        .bind(input.alert_open_day_hours)
780        .execute(&state.pool)
781        .await?;
782
783    audit::Entry::new(audit::action::ALERT_THRESHOLDS_CHANGED)
784        .by(user.user_id)
785        .by_email(&user.email)
786        .with(serde_json::json!({
787            "from": previous,
788            "to": {
789                "alert_silence_hours": input.alert_silence_hours,
790                "alert_overwork_factor": input.alert_overwork_factor,
791                "alert_open_day_hours": input.alert_open_day_hours,
792            }
793        }))
794        .record(&state.pool)
795        .await;
796
797    let thresholds = Thresholds::load(&state.pool).await?;
798    Ok((StatusCode::OK, Json(thresholds)))
799}
800
801// The background sweep --------------------------------------------------------
802
803/// How often the sweep runs.
804///
805/// Five minutes. The conditions are measured in hours, so a finer interval
806/// would buy nothing a manager could act on and would put a query over every
807/// account on a loop. Coarser, and "a day left open" arrives late enough that
808/// somebody has already started working inside the wrong day.
809const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5 * 60);
810
811/// Runs the sweep on a timer, for as long as the server runs.
812///
813/// A background task rather than work done when the feed is opened. The whole
814/// point of an alert is that it exists before anybody looks: an alert computed
815/// on read has no `fired_at` worth the name - it would say the condition began
816/// the moment somebody first opened the page - and could never be delivered
817/// anywhere, which is what v0.23 is for.
818///
819/// A sweep that fails is logged and the loop continues. A database blip must
820/// not leave a server running with its alerts permanently frozen, and the next
821/// sweep reconciles from scratch anyway - there is no state carried between
822/// them to be corrupted by a skipped one.
823pub fn run_sweeps(pool: PgPool) {
824    tokio::spawn(async move {
825        let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
826        // The first tick is immediate: a server that has just started should
827        // not take five minutes to notice the agent that died while it was
828        // down. `Delay` so a burst of missed ticks after a long pause does not
829        // run the sweep several times in a row to catch up.
830        ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
831        loop {
832            ticker.tick().await;
833            match sweep(&pool, Utc::now()).await {
834                Ok(swept) if swept.raised > 0 || swept.resolved > 0 => {
835                    tracing::info!(raised = swept.raised, resolved = swept.resolved, open = swept.unchanged, "swept the alerts");
836                }
837                Ok(_) => {}
838                Err(error) => tracing::warn!(%error, "an alert sweep failed; the next one will reconcile from scratch"),
839            }
840        }
841    });
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::calendar::{CalendarDay, CalendarDayKind};
848
849    fn thresholds() -> Thresholds {
850        Thresholds {
851            alert_silence_hours: 12,
852            alert_overwork_factor: Decimal::new(15, 1),
853            alert_open_day_hours: 16,
854        }
855    }
856
857    fn now() -> DateTime<Utc> {
858        "2026-09-18T12:00:00Z".parse().expect("a fixed moment for the arithmetic")
859    }
860
861    /// Somebody with nothing wrong: an agent that reported an hour ago, an
862    /// ordinary day, no open day.
863    fn quiet_observation() -> Observation {
864        Observation {
865            user_id: Uuid::nil(),
866            last_seen_at: Some(now() - TimeDelta::hours(1)),
867            has_live_agent: true,
868            longest_finished_day: Some(("2026-09-17".parse().unwrap(), 8 * SECONDS_PER_HOUR, WorkdayKind::Work)),
869            open_day: None,
870            work_rate: Decimal::ONE,
871        }
872    }
873
874    fn eight_hours() -> Decimal {
875        Decimal::from(8)
876    }
877
878    #[test]
879    fn an_ordinary_person_raises_nothing() {
880        let found = findings(&quiet_observation(), &Calendar::empty(), eight_hours(), &thresholds(), now());
881        assert!(found.is_empty(), "nothing here is worth interrupting anybody about: {found:?}");
882    }
883
884    #[test]
885    fn silence_past_the_threshold_is_an_alert_and_short_silence_is_not() {
886        let mut observation = quiet_observation();
887
888        // Eleven hours is a night. Twelve is the threshold.
889        observation.last_seen_at = Some(now() - TimeDelta::hours(11));
890        assert!(
891            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
892                .iter()
893                .any(|f| f.rule == AlertRule::NoAgentData),
894            "a night's silence is not an alert",
895        );
896
897        observation.last_seen_at = Some(now() - TimeDelta::hours(13));
898        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
899        let silence = found
900            .iter()
901            .find(|f| f.rule == AlertRule::NoAgentData)
902            .expect("thirteen hours of silence is an alert");
903        assert_eq!(silence.observed_seconds, 13 * SECONDS_PER_HOUR);
904        assert_eq!(
905            silence.against_seconds,
906            Some(12 * SECONDS_PER_HOUR),
907            "the alert carries what it was measured against"
908        );
909        assert_eq!(silence.subject_date, None, "silence is about a person, not about a day");
910    }
911
912    #[test]
913    fn somebody_with_no_agent_is_not_silent() {
914        // The failure this guards is an installation that has not finished
915        // handing out tokens alerting about every account on its first day.
916        // The dashboard already says "no agents" in words, which is a more
917        // useful sentence than "quiet for 400 hours".
918        let mut observation = quiet_observation();
919        observation.has_live_agent = false;
920        observation.last_seen_at = Some(now() - TimeDelta::days(30));
921
922        assert!(
923            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
924                .iter()
925                .any(|f| f.rule == AlertRule::NoAgentData),
926            "nothing was ever asked of this person",
927        );
928    }
929
930    #[test]
931    fn overwork_is_measured_against_that_persons_own_norm() {
932        let mut observation = quiet_observation();
933        let date: NaiveDate = "2026-09-17".parse().unwrap();
934
935        // Full rate: the bar is twelve hours (8 x 1.5). Eleven is a long day
936        // and not an alert.
937        observation.longest_finished_day = Some((date, 11 * SECONDS_PER_HOUR, WorkdayKind::Work));
938        assert!(
939            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
940                .iter()
941                .any(|f| f.rule == AlertRule::Overwork),
942            "eleven hours against an eight-hour norm is under the factor",
943        );
944
945        observation.longest_finished_day = Some((date, 12 * SECONDS_PER_HOUR, WorkdayKind::Work));
946        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
947        let overwork = found
948            .iter()
949            .find(|f| f.rule == AlertRule::Overwork)
950            .expect("twelve hours is half again the norm");
951        assert_eq!(overwork.observed_seconds, 12 * SECONDS_PER_HOUR);
952        assert_eq!(
953            overwork.against_seconds,
954            Some(8 * SECONDS_PER_HOUR),
955            "the norm travels with the alert, not a percentage"
956        );
957        assert_eq!(overwork.subject_date, Some(date));
958
959        // Half time: the same eight-hour day is now double the norm, and the
960        // bar is six. This is the whole reason the threshold is a share and
961        // not a number of hours - an installation-wide "over ten" would never
962        // mention a part-timer working twice their day.
963        observation.work_rate = Decimal::new(5, 1);
964        observation.longest_finished_day = Some((date, 8 * SECONDS_PER_HOUR, WorkdayKind::Work));
965        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
966        let overwork = found
967            .iter()
968            .find(|f| f.rule == AlertRule::Overwork)
969            .expect("a full day at half rate is double the norm");
970        assert_eq!(overwork.against_seconds, Some(4 * SECONDS_PER_HOUR));
971    }
972
973    #[test]
974    fn a_short_day_before_a_holiday_lowers_the_bar() {
975        // The point of reading the norm from the calendar rather than from
976        // `standard_hours` alone: on a short day the norm is seven, so the
977        // bar is 10.5 rather than 12, and ten and a half hours on the eve of
978        // a holiday is exactly the case worth noticing.
979        let eve: NaiveDate = "2026-09-17".parse().unwrap();
980        let calendar = Calendar::from_days(vec![CalendarDay {
981            date: eve,
982            kind: CalendarDayKind::ShortDay,
983            note: None,
984        }]);
985
986        let mut observation = quiet_observation();
987        observation.longest_finished_day = Some((eve, 11 * SECONDS_PER_HOUR, WorkdayKind::Work));
988
989        let found = findings(&observation, &calendar, eight_hours(), &thresholds(), now());
990        let overwork = found
991            .iter()
992            .find(|f| f.rule == AlertRule::Overwork)
993            .expect("eleven hours against a seven-hour norm is overwork");
994        assert_eq!(overwork.against_seconds, Some(7 * SECONDS_PER_HOUR));
995
996        // And with no calendar the same day is an ordinary long one.
997        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
998        assert!(
999            !found.iter().any(|f| f.rule == AlertRule::Overwork),
1000            "the same eleven hours on a full-norm day is under the factor",
1001        );
1002    }
1003
1004    #[test]
1005    fn a_day_the_calendar_does_not_ask_for_raises_no_overwork() {
1006        // A Saturday has a norm of zero, and no multiple of zero is anything.
1007        // Working one is worth noticing and this is not the rule that notices
1008        // it: there is no norm here to be a factor of, so a threshold would be
1009        // a number invented after all.
1010        let saturday: NaiveDate = "2026-09-19".parse().unwrap();
1011        assert_eq!(saturday.format("%a").to_string(), "Sat", "the fixture has to actually be a Saturday");
1012
1013        let mut observation = quiet_observation();
1014        observation.longest_finished_day = Some((saturday, 14 * SECONDS_PER_HOUR, WorkdayKind::Work));
1015
1016        assert!(
1017            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
1018                .iter()
1019                .any(|f| f.rule == AlertRule::Overwork),
1020        );
1021    }
1022
1023    #[test]
1024    fn a_day_of_leave_raises_no_overwork() {
1025        // Its norm is zero, so any work at all would exceed it by an infinite
1026        // share - and "you worked on your holiday" is between the employee and
1027        // their own screen, not something to raise with a manager.
1028        let mut observation = quiet_observation();
1029        for kind in [WorkdayKind::Vacation, WorkdayKind::Sick, WorkdayKind::DayOff] {
1030            observation.longest_finished_day = Some(("2026-09-17".parse().unwrap(), 12 * SECONDS_PER_HOUR, kind));
1031            assert!(
1032                !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
1033                    .iter()
1034                    .any(|f| f.rule == AlertRule::Overwork),
1035                "{kind:?} owes no norm",
1036            );
1037        }
1038    }
1039
1040    #[test]
1041    fn a_day_open_too_long_is_an_alert() {
1042        let mut observation = quiet_observation();
1043        let date: NaiveDate = "2026-09-18".parse().unwrap();
1044
1045        // Fifteen hours is a very long day somebody may still be inside.
1046        observation.open_day = Some((date, now() - TimeDelta::hours(15)));
1047        assert!(
1048            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
1049                .iter()
1050                .any(|f| f.rule == AlertRule::DayNotClosed),
1051        );
1052
1053        observation.open_day = Some((date, now() - TimeDelta::hours(17)));
1054        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
1055        let open = found
1056            .iter()
1057            .find(|f| f.rule == AlertRule::DayNotClosed)
1058            .expect("seventeen hours open is past any day");
1059        assert_eq!(open.observed_seconds, 17 * SECONDS_PER_HOUR);
1060        assert_eq!(open.against_seconds, Some(16 * SECONDS_PER_HOUR));
1061        assert_eq!(open.subject_date, Some(date));
1062    }
1063
1064    #[test]
1065    fn an_open_day_is_judged_by_the_clock_and_not_by_a_norm() {
1066        // Half time does not make a day that has been open for seventeen hours
1067        // any less open. The rule deliberately does not read `work_rate`, and
1068        // this is what would fail if somebody "made it consistent" with
1069        // overwork.
1070        let mut observation = quiet_observation();
1071        observation.work_rate = Decimal::new(5, 1);
1072        observation.open_day = Some(("2026-09-18".parse().unwrap(), now() - TimeDelta::hours(17)));
1073
1074        assert!(
1075            findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
1076                .iter()
1077                .any(|f| f.rule == AlertRule::DayNotClosed),
1078        );
1079    }
1080
1081    #[test]
1082    fn the_thresholds_are_what_moves_the_line() {
1083        // A change an operator makes has to actually change the answer - the
1084        // failure this guards is a threshold read from settings and then
1085        // ignored in favour of a constant.
1086        let mut observation = quiet_observation();
1087        observation.last_seen_at = Some(now() - TimeDelta::hours(5));
1088
1089        let strict = Thresholds {
1090            alert_silence_hours: 4,
1091            ..thresholds()
1092        };
1093        assert!(
1094            findings(&observation, &Calendar::empty(), eight_hours(), &strict, now())
1095                .iter()
1096                .any(|f| f.rule == AlertRule::NoAgentData),
1097            "five hours of silence is an alert where the threshold is four",
1098        );
1099        assert!(
1100            !findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now())
1101                .iter()
1102                .any(|f| f.rule == AlertRule::NoAgentData),
1103            "and is not where the threshold is twelve",
1104        );
1105    }
1106
1107    #[test]
1108    fn several_rules_can_be_true_at_once() {
1109        // They are independent facts about one person, and a feed that showed
1110        // only the worst would hide the open day behind the silence.
1111        let observation = Observation {
1112            user_id: Uuid::nil(),
1113            last_seen_at: Some(now() - TimeDelta::hours(20)),
1114            has_live_agent: true,
1115            longest_finished_day: Some(("2026-09-17".parse().unwrap(), 13 * SECONDS_PER_HOUR, WorkdayKind::Work)),
1116            open_day: Some(("2026-09-18".parse().unwrap(), now() - TimeDelta::hours(20))),
1117            work_rate: Decimal::ONE,
1118        };
1119
1120        let found = findings(&observation, &Calendar::empty(), eight_hours(), &thresholds(), now());
1121        assert_eq!(found.len(), 3, "silence, overwork and an open day are three separate things: {found:?}");
1122    }
1123
1124    #[test]
1125    fn silence_outranks_the_others_in_the_feed() {
1126        // An agent that stopped reporting makes every other number about that
1127        // person untrustworthy, including the two below it.
1128        let mut rules = [AlertRule::Overwork, AlertRule::NoAgentData, AlertRule::DayNotClosed];
1129        rules.sort_by_key(|rule| rule.severity());
1130        assert_eq!(rules, [AlertRule::NoAgentData, AlertRule::DayNotClosed, AlertRule::Overwork]);
1131    }
1132}