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