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