Skip to main content

kasl_server/
me.rs

1//! What a person can read about themselves: `GET /api/v1/me/days`.
2//!
3//! The first read endpoint this server has. Every route before it either took
4//! data in or described the installation; this one hands a day back, and its
5//! shape is reused by the manager's drill-down in [`crate::team`] - the same
6//! answer for a different subject, through [`days_for`].
7//!
8//! Two decisions stand behind it, both taken before the code:
9//!
10//! * **A range, not a day.** The screen draws a week, the calendar a month,
11//!   and a drill-down one date - all three are `from`/`to` with different
12//!   ends. An endpoint per screen would make the API a description of the
13//!   current UI rather than a contract.
14//! * **`/me`, not `/users/{id}` with your own id.** Nothing here consults a
15//!   role or a department, so no reading of them can be wrong. Someone else's
16//!   days go through [`crate::team`], where the permission is the subject and
17//!   is checked in the open.
18//!
19//! The response says what the privacy level withheld, rather than answering an
20//! empty list. Under `coarse` the server stores no individual pauses, and a
21//! screen that draws nothing there would tell the employee they worked without
22//! a break - the reassuring reading, and the false one (ADR 0011).
23
24use axum::{
25    Json,
26    extract::{Query, State},
27    response::IntoResponse,
28};
29use chrono::{DateTime, NaiveDate, Utc};
30use serde::{Deserialize, Serialize};
31use sqlx::PgPool;
32use uuid::Uuid;
33
34use crate::{
35    app::AppState,
36    error::ApiError,
37    login::CurrentUser,
38    privacy::{Policy, PrivacyLevel},
39};
40
41/// The widest range one request may ask for, in days.
42///
43/// A year and a bit: enough for "my whole history" on the screens that offer
44/// it, and bounded so a hand-written query cannot ask the server to serialize
45/// an installation's lifetime in one response.
46pub const MAX_RANGE_DAYS: i64 = 400;
47
48/// The period being asked for. Both ends inclusive - `from=2026-08-27` and
49/// `to=2026-08-27` is one day, which is what a person writing the query by
50/// hand expects.
51#[derive(Debug, Deserialize)]
52pub struct Range {
53    pub from: NaiveDate,
54    pub to: NaiveDate,
55}
56
57/// One day, with everything stored under it.
58///
59/// Field names follow `DayUpload` where they mean the same thing: an agent
60/// author reading both ends of the API should not have to learn two
61/// vocabularies for one day.
62#[derive(Debug, Serialize)]
63pub struct Day {
64    pub date: NaiveDate,
65    pub started_at: DateTime<Utc>,
66    /// Absent while the day is still open on the agent.
67    pub ended_at: Option<DateTime<Utc>>,
68    /// Seconds between start and end, minus what was paused. `None` for a day
69    /// that has not ended: a half-finished day has no total, and reporting the
70    /// hours so far as the day's figure would make an open day look short.
71    pub worked_seconds: Option<i64>,
72    /// How many times the day was interrupted, and for how long in total.
73    /// Always answered - computed from the stored pauses where they exist, and
74    /// read off the day where the policy summarized them away.
75    pub paused_count: i64,
76    pub paused_seconds: i64,
77    pub pauses: Vec<Pause>,
78    pub tasks: Vec<Task>,
79}
80
81#[derive(Debug, Serialize, sqlx::FromRow)]
82pub struct Pause {
83    pub id: Uuid,
84    pub started_at: DateTime<Utc>,
85    pub ended_at: Option<DateTime<Utc>>,
86    pub duration_seconds: Option<i32>,
87    /// A break the employee entered by hand, as opposed to detected idleness.
88    pub manual: bool,
89    /// The text they typed with it, where the policy keeps free text.
90    pub reason: Option<String>,
91}
92
93#[derive(Debug, Serialize, sqlx::FromRow)]
94pub struct Task {
95    pub id: Uuid,
96    pub name: String,
97    pub comment: Option<String>,
98    pub completeness: i16,
99    pub recorded_at: DateTime<Utc>,
100}
101
102/// The answer: the days, and what the installation's policy left out of them.
103#[derive(Debug, Serialize)]
104pub struct Days {
105    pub from: NaiveDate,
106    pub to: NaiveDate,
107    pub days: Vec<Day>,
108    /// The level in force now. Not necessarily the one these days were stored
109    /// under - narrowing does not rewrite history (ADR 0011) - which is why
110    /// the omissions below are stated per kind rather than inferred from it.
111    pub privacy_level: PrivacyLevel,
112    /// Kinds of detail this installation does not keep, named so a screen can
113    /// say "not stored" where it would otherwise show an empty section. An
114    /// empty list means nothing is withheld.
115    pub not_stored: Vec<&'static str>,
116}
117
118/// What a level withholds, in the words a screen can show as-is.
119pub fn not_stored_at(level: PrivacyLevel) -> Vec<&'static str> {
120    let mut withheld = Vec::new();
121    if !level.keeps_pause_times() {
122        withheld.push("pauses");
123    }
124    if !level.keeps_tasks() {
125        withheld.push("tasks");
126    }
127    if !level.keeps_free_text() {
128        withheld.push("free_text");
129    }
130    withheld
131}
132
133/// Answers the signed-in person's own days.
134pub async fn days(State(state): State<AppState>, user: CurrentUser, Query(range): Query<Range>) -> Result<impl IntoResponse, ApiError> {
135    validate_range(&range)?;
136    Ok(Json(days_for(&state.pool, user.user_id, &range).await?))
137}
138
139/// Builds one person's answer, whoever is asking.
140///
141/// Shared with the manager's drill-down, which is this screen pointed at
142/// someone else: the permission differs, the answer must not. Callers check who
143/// may read what before they get here.
144pub async fn days_for(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Days, ApiError> {
145    let level = Policy::load(pool).await?.level();
146    let days = load_days(pool, user_id, range).await?;
147
148    Ok(Days {
149        from: range.from,
150        to: range.to,
151        days,
152        privacy_level: level,
153        not_stored: not_stored_at(level),
154    })
155}
156
157/// Rejects a range the server will not serve, with the reason.
158///
159/// Separate from the handler so the rules can be read - and tested - without a
160/// database behind them.
161pub fn validate_range(range: &Range) -> Result<(), ApiError> {
162    if range.to < range.from {
163        return Err(ApiError::bad_request("`to` is before `from`"));
164    }
165    // Inclusive on both ends, so a single day is a span of zero.
166    let span = (range.to - range.from).num_days() + 1;
167    if span > MAX_RANGE_DAYS {
168        return Err(ApiError::bad_request(format!(
169            "a range covers at most {MAX_RANGE_DAYS} days, this one covers {span}"
170        )));
171    }
172    Ok(())
173}
174
175/// Loads the days in a range with their pauses and tasks.
176///
177/// Three queries rather than one join: a day joined to both its pauses and its
178/// tasks multiplies the rows, and reassembling that in Rust is where an hour
179/// gets counted twice. Each query is bounded by the same range, which the
180/// handler has already capped.
181async fn load_days(pool: &PgPool, user_id: Uuid, range: &Range) -> Result<Vec<Day>, ApiError> {
182    let workdays: Vec<WorkdayRow> = sqlx::query_as(
183        r#"
184        SELECT id, date, started_at, ended_at, paused_count, paused_seconds
185        FROM workdays
186        WHERE user_id = $1 AND date BETWEEN $2 AND $3
187        ORDER BY date
188        "#,
189    )
190    .bind(user_id)
191    .bind(range.from)
192    .bind(range.to)
193    .fetch_all(pool)
194    .await?;
195
196    if workdays.is_empty() {
197        return Ok(Vec::new());
198    }
199
200    let workday_ids: Vec<Uuid> = workdays.iter().map(|day| day.id).collect();
201
202    let pauses: Vec<(Uuid, Pause)> = sqlx::query_as::<_, PauseRow>(
203        r#"
204        SELECT workday_id, id, started_at, ended_at, duration_seconds, manual, reason
205        FROM pauses
206        WHERE workday_id = ANY($1)
207        ORDER BY started_at
208        "#,
209    )
210    .bind(&workday_ids)
211    .fetch_all(pool)
212    .await?
213    .into_iter()
214    .map(PauseRow::split)
215    .collect();
216
217    // Tasks hang off the user and a date, not off the workday: kasl carries the
218    // same task across days, and a task can be logged on a date whose workday
219    // never arrived.
220    let tasks: Vec<(NaiveDate, Task)> = sqlx::query_as::<_, TaskRow>(
221        r#"
222        SELECT date, id, name, comment, completeness, recorded_at
223        FROM tasks
224        WHERE user_id = $1 AND date BETWEEN $2 AND $3
225        ORDER BY recorded_at
226        "#,
227    )
228    .bind(user_id)
229    .bind(range.from)
230    .bind(range.to)
231    .fetch_all(pool)
232    .await?
233    .into_iter()
234    .map(TaskRow::split)
235    .collect();
236
237    Ok(workdays.into_iter().map(|row| row.into_day(&pauses, &tasks)).collect())
238}
239
240/// A workday as stored, before its pauses and tasks are attached.
241#[derive(Debug, sqlx::FromRow)]
242struct WorkdayRow {
243    id: Uuid,
244    date: NaiveDate,
245    started_at: DateTime<Utc>,
246    ended_at: Option<DateTime<Utc>>,
247    paused_count: Option<i32>,
248    paused_seconds: Option<i32>,
249}
250
251impl WorkdayRow {
252    fn into_day(self, pauses: &[(Uuid, Pause)], tasks: &[(NaiveDate, Task)]) -> Day {
253        let own_pauses: Vec<Pause> = pauses.iter().filter(|(id, _)| *id == self.id).map(|(_, pause)| pause.clone_row()).collect();
254        let own_tasks: Vec<Task> = tasks.iter().filter(|(date, _)| *date == self.date).map(|(_, task)| task.clone_row()).collect();
255
256        // Two sources for one figure, and only one of them exists at a time.
257        // Where pauses are stored, they are the count; where the policy
258        // summarized them away, the day carries what they came to (ADR 0011).
259        // Preferring the stored rows keeps the number consistent with the
260        // timeline drawn next to it.
261        let (paused_count, paused_seconds) = if own_pauses.is_empty() && (self.paused_count.is_some() || self.paused_seconds.is_some()) {
262            (i64::from(self.paused_count.unwrap_or(0)), i64::from(self.paused_seconds.unwrap_or(0)))
263        } else {
264            let seconds = own_pauses.iter().filter_map(|pause| pause.duration_seconds).map(i64::from).sum();
265            (own_pauses.len() as i64, seconds)
266        };
267
268        let worked_seconds = self.ended_at.map(|ended| ((ended - self.started_at).num_seconds() - paused_seconds).max(0));
269
270        Day {
271            date: self.date,
272            started_at: self.started_at,
273            ended_at: self.ended_at,
274            worked_seconds,
275            paused_count,
276            paused_seconds,
277            pauses: own_pauses,
278            tasks: own_tasks,
279        }
280    }
281}
282
283/// A pause with the workday it belongs to, for grouping.
284#[derive(Debug, sqlx::FromRow)]
285struct PauseRow {
286    workday_id: Uuid,
287    id: Uuid,
288    started_at: DateTime<Utc>,
289    ended_at: Option<DateTime<Utc>>,
290    duration_seconds: Option<i32>,
291    manual: bool,
292    reason: Option<String>,
293}
294
295impl PauseRow {
296    fn split(self) -> (Uuid, Pause) {
297        (
298            self.workday_id,
299            Pause {
300                id: self.id,
301                started_at: self.started_at,
302                ended_at: self.ended_at,
303                duration_seconds: self.duration_seconds,
304                manual: self.manual,
305                reason: self.reason,
306            },
307        )
308    }
309}
310
311impl Pause {
312    fn clone_row(&self) -> Self {
313        Self {
314            id: self.id,
315            started_at: self.started_at,
316            ended_at: self.ended_at,
317            duration_seconds: self.duration_seconds,
318            manual: self.manual,
319            reason: self.reason.clone(),
320        }
321    }
322}
323
324/// A task with the date it belongs to, for grouping.
325#[derive(Debug, sqlx::FromRow)]
326struct TaskRow {
327    date: NaiveDate,
328    id: Uuid,
329    name: String,
330    comment: Option<String>,
331    completeness: i16,
332    recorded_at: DateTime<Utc>,
333}
334
335impl TaskRow {
336    fn split(self) -> (NaiveDate, Task) {
337        (
338            self.date,
339            Task {
340                id: self.id,
341                name: self.name,
342                comment: self.comment,
343                completeness: self.completeness,
344                recorded_at: self.recorded_at,
345            },
346        )
347    }
348}
349
350impl Task {
351    fn clone_row(&self) -> Self {
352        Self {
353            id: self.id,
354            name: self.name.clone(),
355            comment: self.comment.clone(),
356            completeness: self.completeness,
357            recorded_at: self.recorded_at,
358        }
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn range(from: &str, to: &str) -> Range {
367        Range {
368            from: from.parse().expect("a test date"),
369            to: to.parse().expect("a test date"),
370        }
371    }
372
373    #[test]
374    fn a_single_day_is_a_valid_range() {
375        // Both ends inclusive: the drill-down asks for one date with the same
376        // parameter twice, and rejecting that would make the common case the
377        // awkward one.
378        assert!(validate_range(&range("2026-08-27", "2026-08-27")).is_ok());
379    }
380
381    #[test]
382    fn a_backwards_range_is_refused() {
383        let error = validate_range(&range("2026-08-27", "2026-08-01")).unwrap_err();
384        assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
385        assert!(error.to_string().contains("before"), "the message should say what is wrong: {error}");
386    }
387
388    #[test]
389    fn the_range_has_a_ceiling() {
390        // The boundary itself, on both sides: an off-by-one here either
391        // refuses a legitimate year or lifts the cap the test claims to guard.
392        let widest = range("2026-01-01", "2027-02-04");
393        assert_eq!((widest.to - widest.from).num_days() + 1, MAX_RANGE_DAYS);
394        assert!(validate_range(&widest).is_ok(), "exactly {MAX_RANGE_DAYS} days is allowed");
395
396        let too_wide = range("2026-01-01", "2027-02-05");
397        let error = validate_range(&too_wide).unwrap_err();
398        assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST);
399        assert!(error.to_string().contains("401"), "the message should name the span asked for: {error}");
400    }
401
402    #[test]
403    fn a_level_names_what_it_withholds() {
404        // What the screen renders as "the server does not store this". Under
405        // `full` the list is empty, and an empty section then really does mean
406        // nothing happened.
407        assert!(not_stored_at(PrivacyLevel::Full).is_empty());
408        assert_eq!(not_stored_at(PrivacyLevel::Moderate), vec!["free_text"]);
409        assert_eq!(not_stored_at(PrivacyLevel::Coarse), vec!["pauses", "tasks", "free_text"]);
410    }
411}