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