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}