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}