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    error::ApiError,
39    heartbeat::{self, Live},
40    login::CurrentUser,
41    me::{self, Range},
42    model::UserRole,
43    privacy::Policy,
44};
45
46/// One person's period, as the dashboard's table shows it.
47#[derive(Debug, Serialize, sqlx::FromRow)]
48pub struct Member {
49    pub id: Uuid,
50    pub display_name: String,
51    pub email: String,
52    pub department: Option<String>,
53    /// Days with a workday row in the range. Zero is a real answer.
54    pub days_recorded: i64,
55    /// Seconds worked across the range: the span of each finished day less
56    /// what was paused in it. Open days contribute nothing - a day still
57    /// running has no total to add (the same rule `/me/days` follows).
58    pub worked_seconds: i64,
59    pub paused_seconds: i64,
60    /// The most recent date with a workday, so "no data" can be told from
61    /// "nothing since the 12th".
62    pub last_day: Option<NaiveDate>,
63    /// Whether a day is open right now on the employee's own calendar.
64    pub day_open: bool,
65    /// When any of this person's agents last delivered anything.
66    ///
67    /// The honest half of "who is working now": the server knows when it last
68    /// heard from a machine, not whether someone is at it. Live status needs
69    /// heartbeats, which is its own milestone.
70    pub last_seen_at: Option<DateTime<Utc>>,
71    /// Live agent tokens. Zero explains a silent row without guessing.
72    pub agents: i64,
73}
74
75/// The team's period.
76#[derive(Debug, Serialize)]
77pub struct Team {
78    pub from: NaiveDate,
79    pub to: NaiveDate,
80    pub members: Vec<Member>,
81    /// The level in force, so the dashboard can caveat its own figures the way
82    /// the personal page does.
83    pub privacy_level: crate::privacy::PrivacyLevel,
84    pub not_stored: Vec<&'static str>,
85}
86
87/// Answers the team's hours over a range.
88pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
89    require_manager_or_admin(&user)?;
90    me::validate_range(&range)?;
91
92    let is_admin = user.role == UserRole::Admin;
93
94    // `date` here is the employee's own local date, as their agent recorded it,
95    // and "today" is the server's. They can differ by a day at the edges; for
96    // "is a day open" that is the right approximation - the alternative needs a
97    // per-person time zone the server does not store (ADR 0003).
98    // `AssertSqlSafe` because the only interpolation is `VISIBLE_USERS`, a
99    // constant in `admin`; every value from the request is bound below.
100    let members: Vec<Member> = sqlx::query_as(sqlx::AssertSqlSafe(format!(
101        "SELECT u.id, u.display_name, u.email, d.name AS department,
102                coalesce(w.days_recorded, 0)::bigint AS days_recorded,
103                coalesce(w.worked_seconds, 0)::bigint AS worked_seconds,
104                coalesce(w.paused_seconds, 0)::bigint AS paused_seconds,
105                w.last_day,
106                coalesce(o.day_open, false) AS day_open,
107                (SELECT max(a.last_seen_at) FROM agents a WHERE a.user_id = u.id) AS last_seen_at,
108                (SELECT count(*) FROM agents a WHERE a.user_id = u.id AND a.revoked_at IS NULL) AS agents
109         FROM users u
110         LEFT JOIN departments d ON d.id = u.department_id
111         LEFT JOIN LATERAL (
112             -- `sum()` over bigint answers `numeric`, which does not decode
113             -- into an i64; the cast is outside the sum so it happens once.
114             SELECT count(*) AS days_recorded,
115                    max(w.date) AS last_day,
116                    coalesce(sum(
117                        CASE WHEN w.ended_at IS NULL THEN 0
118                             ELSE greatest(extract(epoch FROM (w.ended_at - w.started_at))::bigint - paused.seconds, 0)
119                        END
120                    ), 0)::bigint AS worked_seconds,
121                    coalesce(sum(paused.seconds), 0)::bigint AS paused_seconds
122             FROM workdays w
123             CROSS JOIN LATERAL (
124                 -- Stored pauses where they exist; the day's own totals where a
125                 -- narrower policy summarized them away (ADR 0011). One or the
126                 -- other, never both, so the hours cannot be double-counted.
127                 SELECT CASE
128                     WHEN EXISTS (SELECT 1 FROM pauses p WHERE p.workday_id = w.id)
129                     THEN (SELECT coalesce(sum(p.duration_seconds), 0)::bigint FROM pauses p WHERE p.workday_id = w.id)
130                     ELSE coalesce(w.paused_seconds, 0)::bigint
131                 END AS seconds
132             ) AS paused
133             WHERE w.user_id = u.id AND w.date BETWEEN $3 AND $4
134         ) AS w ON true
135         LEFT JOIN LATERAL (
136             SELECT true AS day_open
137             FROM workdays w2
138             WHERE w2.user_id = u.id AND w2.date = current_date AND w2.ended_at IS NULL
139             LIMIT 1
140         ) AS o ON true
141         WHERE u.active AND {VISIBLE_USERS}
142         ORDER BY u.display_name, u.email"
143    )))
144    .bind(is_admin)
145    .bind(user.user_id)
146    .bind(range.from)
147    .bind(range.to)
148    .fetch_all(&state.pool)
149    .await?;
150
151    let level = Policy::load(&state.pool).await?.level();
152
153    Ok(Json(Team {
154        from: range.from,
155        to: range.to,
156        members,
157        privacy_level: level,
158        not_stored: me::not_stored_at(level),
159    }))
160}
161
162/// Answers one person's days to someone allowed to see them.
163///
164/// Deliberately the same response shape as `/me/days`: the drill-down is the
165/// personal screen pointed at someone else, and two shapes for one thing would
166/// mean two renderers to keep in step.
167pub async fn user_days(
168    State(state): State<AppState>,
169    user: CurrentUser,
170    Path(target): Path<Uuid>,
171    Query(range): Query<Range>,
172) -> Result<impl IntoResponse, ApiError> {
173    require_manager_or_admin(&user)?;
174    me::validate_range(&range)?;
175
176    if !may_read(&state.pool, &user, target).await? {
177        // Not "no such user": a manager probing ids should not be able to tell
178        // an employee in another department from one who does not exist.
179        return Err(ApiError::new(StatusCode::NOT_FOUND, "no such user"));
180    }
181
182    Ok(Json(me::days_for(&state.pool, target, &range).await?))
183}
184
185/// What the team is doing right now: `GET /api/v1/team/live`.
186///
187/// Its own endpoint rather than a field on `/team/days`, because the two are
188/// asked at completely different rates. The week's hours are a page load; the
189/// pulse is a poll every half minute, and answering it with the week's totals
190/// would make the dashboard re-run the heaviest query on the server on a timer
191/// - for numbers that did not change.
192///
193/// Keyed by user id so the caller merges it into the table it already drew.
194/// Nothing here identifies a person beyond that id: the row a manager may see
195/// was decided by `/team/days`, and this endpoint applies the same clause
196/// rather than a second reading of it.
197#[derive(Debug, Serialize)]
198pub struct LiveTeam {
199    pub members: Vec<Live>,
200    /// How often the caller should ask again, in seconds. The server owns the
201    /// cadence - it is the side that knows the staleness threshold.
202    pub poll_seconds: i64,
203    /// After how many seconds of silence a pulse stops being believed, so the
204    /// UI can explain "offline" with the same number the server used.
205    pub stale_after_seconds: i64,
206}
207
208/// Answers the live status of everyone the reader may see.
209pub async fn live(State(state): State<AppState>, user: CurrentUser) -> Result<impl IntoResponse, ApiError> {
210    require_manager_or_admin(&user)?;
211
212    let members = heartbeat::load(&state.pool, VISIBLE_USERS, user.role == UserRole::Admin, user.user_id).await?;
213
214    Ok(Json(LiveTeam {
215        members,
216        // Half the agent's interval: a dashboard that polled at exactly the
217        // pulse rate would show every state one full interval late, having
218        // just missed each arrival.
219        poll_seconds: heartbeat::INTERVAL_SECONDS / 2,
220        stale_after_seconds: heartbeat::STALE_AFTER_SECONDS,
221    }))
222}
223
224/// Whether `reader` may see `target`'s data.
225///
226/// Asked of the database with the same clause the listing uses, rather than
227/// reasoned about in Rust: the rule and the check cannot drift if they are the
228/// same string.
229async fn may_read(pool: &PgPool, reader: &CurrentUser, target: Uuid) -> Result<bool, ApiError> {
230    let visible: Option<Uuid> = sqlx::query_scalar(sqlx::AssertSqlSafe(format!("SELECT u.id FROM users u WHERE u.id = $3 AND {VISIBLE_USERS}")))
231        .bind(reader.role == UserRole::Admin)
232        .bind(reader.user_id)
233        .bind(target)
234        .fetch_optional(pool)
235        .await?;
236
237    Ok(visible.is_some())
238}