kasl_server/team.rs
1//! What a manager may read about other people: `GET /api/v1/team/days` and
2//! `GET /api/v1/users/{id}/days`.
3//!
4//! This is where the server starts answering for someone other than the caller,
5//! so the permission is the subject of this module rather than a detail inside
6//! it. Two rules, both settled before the code:
7//!
8//! * **The visibility rule lives in one place.** [`admin::VISIBLE_USERS`] is
9//! pasted into every query here. A second copy of "who may see whom" is a
10//! second chance for one of them to widen, and a leak of this kind is
11//! invisible to the person leaked about.
12//! * **A summary, not a pile of days.** The dashboard shows a row per person;
13//! the timeline of one day belongs to the drill-down, which reuses the shape
14//! `/me/days` already answers. A single endpoint carrying every pause of
15//! twenty people for a month would ship a payload nobody on that screen
16//! reads.
17//!
18//! A person the reader may see is listed **even with nothing recorded**. The
19//! employee whose agent has never reported is exactly who a manager needs to
20//! notice, and dropping them from the table hides the case the dashboard exists
21//! for - the same "emptiness lies by default" defect the privacy work fixed at
22//! ingest (ADR 0011).
23
24use axum::{
25 Json,
26 extract::{Path, Query, State},
27 http::StatusCode,
28 response::IntoResponse,
29};
30use chrono::{DateTime, NaiveDate, Utc};
31use serde::Serialize;
32use sqlx::PgPool;
33use uuid::Uuid;
34
35use crate::{
36 admin::{VISIBLE_USERS, require_manager_or_admin},
37 app::AppState,
38 calendar::{Calendar, Norm},
39 error::ApiError,
40 heartbeat::{self, Live},
41 login::CurrentUser,
42 me::{self, Range},
43 model::UserRole,
44 privacy::Policy,
45};
46use rust_decimal::Decimal;
47
48/// One person's period, as the dashboard's table shows it.
49#[derive(Debug, Serialize, sqlx::FromRow)]
50pub struct Member {
51 pub id: Uuid,
52 pub display_name: String,
53 pub email: String,
54 pub department: Option<String>,
55 /// Days worked in the range. Zero is a real answer.
56 ///
57 /// Days the person was away are **not** counted here - they have their own
58 /// figure below, and a field that mixed them would make "four days, twenty
59 /// hours" describe somebody who worked two of them. The pair is what the
60 /// row reads from.
61 pub days_recorded: i64,
62 /// Seconds worked across the range: the span of each finished day less
63 /// what was paused in it. Open days contribute nothing - a day still
64 /// running has no total to add (the same rule `/me/days` follows).
65 pub worked_seconds: i64,
66 pub paused_seconds: i64,
67 /// The most recent date with a workday, so "no data" can be told from
68 /// "nothing since the 12th".
69 pub last_day: Option<NaiveDate>,
70 /// Whether a day is open right now on the employee's own calendar.
71 pub day_open: bool,
72 /// When any of this person's agents last delivered anything.
73 ///
74 /// The honest half of "who is working now": the server knows when it last
75 /// heard from a machine, not whether someone is at it. Live status needs
76 /// heartbeats, which is its own milestone.
77 pub last_seen_at: Option<DateTime<Utc>>,
78 /// Live agent tokens. Zero explains a silent row without guessing.
79 pub agents: i64,
80 /// This person's share of a full day. Carried so a row that is half the
81 /// team's hours can be read as half time rather than as half-hearted.
82 pub work_rate: Decimal,
83 /// What the range asked of this person, in seconds: the calendar at their
84 /// rate, with the days they were away taken out (ADR 0017).
85 ///
86 /// Not a column of the query: the calendar is one set of rows for the
87 /// whole team, so the norm is computed once per person in Rust rather than
88 /// joined per row.
89 #[sqlx(default)]
90 pub norm_seconds: i64,
91 /// Days in the range the person was on leave or ill, so a row short of its
92 /// norm can be read without opening it.
93 #[sqlx(default)]
94 pub days_away: i64,
95}
96
97/// The team's period.
98#[derive(Debug, Serialize)]
99pub struct Team {
100 pub from: NaiveDate,
101 pub to: NaiveDate,
102 pub members: Vec<Member>,
103 /// The level in force, so the dashboard can caveat its own figures the way
104 /// the personal page does.
105 pub privacy_level: crate::privacy::PrivacyLevel,
106 pub not_stored: Vec<&'static str>,
107 /// The installation's full day, in hours. The one figure every norm in the
108 /// table is computed from, stated once rather than per row.
109 pub standard_hours: Decimal,
110}
111
112/// Answers the team's hours over a range.
113pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
114 require_manager_or_admin(&user)?;
115 me::validate_range(&range)?;
116
117 let is_admin = user.role == UserRole::Admin;
118
119 // `date` here is the employee's own local date, as their agent recorded it,
120 // and "today" is the server's. They can differ by a day at the edges; for
121 // "is a day open" that is the right approximation - the alternative needs a
122 // per-person time zone the server does not store (ADR 0003).
123 // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
124 // constant in `admin`; every value from the request is bound below.
125 let members: Vec<Member> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
126 "SELECT u.id, u.display_name, u.email, d.name AS department,
127 u.work_rate,
128 coalesce(w.days_away, 0)::bigint AS days_away,
129 coalesce(w.days_recorded, 0)::bigint AS days_recorded,
130 coalesce(w.worked_seconds, 0)::bigint AS worked_seconds,
131 coalesce(w.paused_seconds, 0)::bigint AS paused_seconds,
132 w.last_day,
133 coalesce(o.day_open, false) AS day_open,
134 (SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
135 (SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents
136 FROM users u
137 LEFT JOIN departments d ON d.id = u.department_id
138 LEFT JOIN LATERAL (
139 -- `sum()` over bigint answers `numeric`, which does not decode
140 -- into an i64; the cast is outside the sum so it happens once.
141 SELECT count(*) FILTER (WHERE w.kind = 'work') AS days_recorded,
142 -- Days the employee told us they were away. Counted here
143 -- rather than in a second query: the same scan already has
144 -- the range's rows in hand, and the two counts partition
145 -- the days rather than overlapping.
146 count(*) FILTER (WHERE w.kind <> 'work') AS days_away,
147 max(w.date) AS last_day,
148 coalesce(sum(
149 CASE WHEN w.ended_at IS NULL THEN 0
150 ELSE greatest(extract(epoch FROM (w.ended_at - w.started_at))::bigint - paused.seconds, 0)
151 END
152 ), 0)::bigint AS worked_seconds,
153 coalesce(sum(paused.seconds), 0)::bigint AS paused_seconds
154 FROM workdays w
155 CROSS JOIN LATERAL (
156 -- Stored pauses where they exist; the day's own totals where a
157 -- narrower policy summarized them away (ADR 0011). One or the
158 -- other, never both, so the hours cannot be double-counted.
159 SELECT CASE
160 WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)
161 THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = w.id)
162 ELSE coalesce(w.paused_seconds, 0)::bigint
163 END AS seconds
164 ) AS paused
165 WHERE w.user_id = u.id AND w.date BETWEEN $3 AND $4
166 ) AS w ON true
167 LEFT JOIN LATERAL (
168 SELECT true AS day_open
169 FROM workdays w2
170 WHERE w2.user_id = u.id AND w2.date = current_date AND w2.ended_at IS NULL
171 LIMIT 1
172 ) AS o ON true
173 WHERE u.active AND {VISIBLE_USERS}
174 ORDER BY u.display_name, u.email"
175 )))
176 .bind(is_admin)
177 .bind(user.user_id)
178 .bind(range.from)
179 .bind(range.to)
180 .fetch_all(&state.pool)
181 .await?;
182
183 let level = Policy::load(&state.pool).await?.level();
184
185 // One calendar for the whole table, and one query for the leave dates.
186 // The norm differs per person only by their rate and by the days they were
187 // away, so nothing here needs a round trip per row.
188 let calendar = Calendar::load(&state.pool, range.from, range.to).await?;
189 let standard_hours = Norm::standard_hours(&state.pool).await?;
190 let away = away_by_user(&state.pool, &members, &range).await?;
191
192 let mut members = members;
193 for member in &mut members {
194 let norm = Norm {
195 standard_hours,
196 work_rate: member.work_rate,
197 };
198 let theirs = away.iter().filter(|(id, _)| *id == member.id).map(|(_, date)| *date).collect::<Vec<_>>();
199 member.norm_seconds = norm.for_range(&calendar, range.from, range.to, &theirs);
200 }
201
202 Ok(Json(Team {
203 from: range.from,
204 to: range.to,
205 members,
206 privacy_level: level,
207 not_stored: me::not_stored_at(level),
208 standard_hours,
209 }))
210}
211
212/// The dates in the range each listed person was away.
213///
214/// One query for the whole table rather than one per row, and scoped to the
215/// people already listed - the visibility rule was applied when they were
216/// selected, and re-deriving it here would be the second copy this module
217/// exists to avoid.
218async fn away_by_user(pool: &PgPool, members: &[Member], range: &Range) -> Result<Vec<(Uuid, NaiveDate)>, ApiError> {
219 if members.is_empty() {
220 return Ok(Vec::new());
221 }
222 let ids: Vec<Uuid> = members.iter().map(|member| member.id).collect();
223
224 let rows: Vec<(Uuid, NaiveDate)> =
225 sqlx::query_as("SELECT user_id, date FROM workdays WHERE user_id = ANY($1) AND date BETWEEN $2 AND $3 AND kind <> 'work'")
226 .bind(&ids)
227 .bind(range.from)
228 .bind(range.to)
229 .fetch_all(pool)
230 .await?;
231
232 Ok(rows)
233}
234
235/// Answers one person's days to someone allowed to see them.
236///
237/// Deliberately the same response shape as `/me/days`: the drill-down is the
238/// personal screen pointed at someone else, and two shapes for one thing would
239/// mean two renderers to keep in step.
240pub async fn user_days(
241 State(state): State<AppState>,
242 user: CurrentUser,
243 Path(target): Path<Uuid>,
244 Query(range): Query<Range>,
245) -> Result<impl IntoResponse, ApiError> {
246 require_manager_or_admin(&user)?;
247 me::validate_range(&range)?;
248
249 if !may_read(&state.pool, &user, target).await? {
250 // Not "no such user": a manager probing ids should not be able to tell
251 // an employee in another department from one who does not exist.
252 return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
253 }
254
255 Ok(Json(me::days_for(&state.pool, target, &range).await?))
256}
257
258/// What the team is doing right now: `GET /api/v1/team/live`.
259///
260/// Its own endpoint rather than a field on `/team/days`, because the two are
261/// asked at completely different rates. The week's hours are a page load; the
262/// pulse is a poll every half minute, and answering it with the week's totals
263/// would make the dashboard re-run the heaviest query on the server on a timer
264/// - for numbers that did not change.
265///
266/// Keyed by user id so the caller merges it into the table it already drew.
267/// Nothing here identifies a person beyond that id: the row a manager may see
268/// was decided by `/team/days`, and this endpoint applies the same clause
269/// rather than a second reading of it.
270#[derive(Debug, Serialize)]
271pub struct LiveTeam {
272 pub members: Vec<Live>,
273 /// How often the caller should ask again, in seconds. The server owns the
274 /// cadence - it is the side that knows the staleness threshold.
275 pub poll_seconds: i64,
276 /// After how many seconds of silence a pulse stops being believed, so the
277 /// UI can explain "offline" with the same number the server used.
278 pub stale_after_seconds: i64,
279}
280
281/// Answers the live status of everyone the reader may see.
282pub async fn live(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
283 require_manager_or_admin(&user)?;
284
285 let members = heartbeat::load(&state.pool, VISIBLE_USERS, user.role == UserRole::Admin, user.user_id).await?;
286
287 Ok(Json(LiveTeam {
288 members,
289 // Half the agent's interval: a dashboard that polled at exactly the
290 // pulse rate would show every state one full interval late, having
291 // just missed each arrival.
292 poll_seconds: heartbeat::INTERVAL_SECONDS / 2,
293 stale_after_seconds: heartbeat::STALE_AFTER_SECONDS,
294 }))
295}
296
297/// Whether `reader` may see `target`'s data.
298///
299/// Asked of the database with the same clause the listing uses, rather than
300/// reasoned about in Rust: the rule and the check cannot drift if they are the
301/// same string.
302async fn may_read(pool: &PgPool, reader: &CurrentUser, target: Uuid) -> Result<bool, ApiError> {
303 let visible: Option<Uuid> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT u.id FROM users u WHERE u.id = $3 AND {VISIBLE_USERS}")))
304 .bind(reader.role == UserRole::Admin)
305 .bind(reader.user_id)
306 .bind(target)
307 .fetch_optional(pool)
308 .await?;
309
310 Ok(visible.is_some())
311}