Skip to main content

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}