Skip to main content

kasl_server/
ingest.rs

1//! The upload endpoint: `POST /api/v1/days`.
2//!
3//! An agent sends one day at a time - the workday, its pauses, and the tasks
4//! recorded on it - and the server writes it whole or not at all.
5//!
6//! Two rules define the contract, both settled before a line of this was
7//! written:
8//!
9//! * **The agent is the source of truth.** A re-upload overwrites what the
10//!   server holds for that date. The employee edits their day in kasl - fixes
11//!   a task, adds a break they took - and the correction has to land. As a
12//!   consequence the same payload sent twice leaves the same rows, which is
13//!   what makes a retry after a lost connection safe.
14//! * **Timestamps carry an offset, and the day carries its own date.** kasl
15//!   stores bare wall-clock text; sending that as-is would make one team's
16//!   hours incomparable across time zones. See ADR 0003.
17
18use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
19use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
20use serde::{Deserialize, Serialize};
21use sqlx::{Postgres, Transaction};
22use uuid::Uuid;
23
24use crate::{app::AppState, auth::AuthenticatedAgent, error::ApiError};
25
26/// One day as the agent recorded it.
27#[derive(Debug, Deserialize)]
28pub struct DayUpload {
29    /// The employee's local calendar date, `YYYY-MM-DD`. Sent explicitly
30    /// rather than derived from `started_at`: which day work belongs to is the
31    /// agent's call, and near midnight the two disagree.
32    pub date: NaiveDate,
33    /// When the day started, with the agent's UTC offset.
34    pub started_at: DateTime<FixedOffset>,
35    /// When it ended; absent while the day is still open.
36    #[serde(default)]
37    pub ended_at: Option<DateTime<FixedOffset>>,
38    #[serde(default)]
39    pub pauses: Vec<PauseUpload>,
40    #[serde(default)]
41    pub tasks: Vec<TaskUpload>,
42}
43
44#[derive(Debug, Deserialize)]
45pub struct PauseUpload {
46    pub started_at: DateTime<FixedOffset>,
47    #[serde(default)]
48    pub ended_at: Option<DateTime<FixedOffset>>,
49    /// Seconds. The agent merges neighbouring pauses before sending, so this
50    /// is not always `ended_at - started_at` and is taken as given.
51    #[serde(default)]
52    pub duration_seconds: Option<i32>,
53    /// A break the employee entered by hand (the agent's `protected` flag).
54    #[serde(default)]
55    pub manual: bool,
56    #[serde(default)]
57    pub reason: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct TaskUpload {
62    /// The agent's own row id. The key a re-upload matches on, so a corrected
63    /// task updates instead of piling up.
64    pub agent_task_id: i32,
65    /// The agent's `task_id`: the same work carried across several days.
66    /// Defaults to `agent_task_id`, which is what the agent stores for a task
67    /// started today.
68    #[serde(default)]
69    pub agent_group_id: Option<i32>,
70    pub recorded_at: DateTime<FixedOffset>,
71    pub name: String,
72    #[serde(default)]
73    pub comment: Option<String>,
74    /// Percent complete, 0..=100.
75    pub completeness: i16,
76}
77
78/// What the agent gets back: enough to log, and to notice a silent no-op.
79#[derive(Debug, Serialize)]
80pub struct DayAccepted {
81    pub workday_id: Uuid,
82    pub date: NaiveDate,
83    pub pauses: usize,
84    pub tasks: usize,
85}
86
87/// Accepts one day from an authenticated agent.
88pub async fn upload_day(State(state): State<AppState>, agent: AuthenticatedAgent, Json(day): Json<DayUpload>) -> Result<impl IntoResponse, ApiError> {
89    validate(&day)?;
90
91    // All of it or none: a day whose pauses landed but whose tasks did not
92    // would show up on a dashboard as real, and nobody would know to re-send.
93    let mut tx = state.pool.begin().await?;
94
95    let workday_id = upsert_workday(&mut tx, agent.user_id, &day).await?;
96    replace_pauses(&mut tx, workday_id, &day.pauses).await?;
97    let tasks = upsert_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?;
98
99    tx.commit().await?;
100
101    // The agent, not just the person: several machines report for one employee
102    // and "which one sent this" is the first question when a day looks wrong.
103    tracing::info!(%workday_id, user_id = %agent.user_id, agent_id = %agent.agent_id, date = %day.date, pauses = day.pauses.len(), tasks, "accepted a day");
104
105    Ok((
106        StatusCode::OK,
107        Json(DayAccepted {
108            workday_id,
109            date: day.date,
110            pauses: day.pauses.len(),
111            tasks,
112        }),
113    ))
114}
115
116/// Rejects payloads the schema would refuse anyway, with a message that says
117/// which field is wrong - a constraint violation surfaces as a 500 and tells
118/// the agent nothing it can act on.
119fn validate(day: &DayUpload) -> Result<(), ApiError> {
120    if let Some(ended_at) = day.ended_at
121        && ended_at < day.started_at
122    {
123        return Err(ApiError::bad_request("ended_at is before started_at"));
124    }
125
126    for (index, pause) in day.pauses.iter().enumerate() {
127        if let Some(ended_at) = pause.ended_at
128            && ended_at < pause.started_at
129        {
130            return Err(ApiError::bad_request(format!("pauses[{index}]: ended_at is before started_at")));
131        }
132        if pause.duration_seconds.is_some_and(|seconds| seconds < 0) {
133            return Err(ApiError::bad_request(format!("pauses[{index}]: duration_seconds is negative")));
134        }
135    }
136
137    for (index, task) in day.tasks.iter().enumerate() {
138        if !(0..=100).contains(&task.completeness) {
139            return Err(ApiError::bad_request(format!("tasks[{index}]: completeness must be between 0 and 100")));
140        }
141        if task.name.trim().is_empty() {
142            return Err(ApiError::bad_request(format!("tasks[{index}]: name is empty")));
143        }
144    }
145
146    Ok(())
147}
148
149/// Writes the day, or corrects the one already stored for that date.
150async fn upsert_workday(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, day: &DayUpload) -> Result<Uuid, ApiError> {
151    let workday_id: Uuid = sqlx::query_scalar(
152        "INSERT INTO workdays (user_id, date, started_at, ended_at) VALUES ($1, $2, $3, $4)
153         ON CONFLICT (user_id, date) DO UPDATE SET started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at
154         RETURNING id",
155    )
156    .bind(user_id)
157    .bind(day.date)
158    .bind(day.started_at.with_timezone(&Utc))
159    .bind(day.ended_at.map(|at| at.with_timezone(&Utc)))
160    .fetch_one(&mut **tx)
161    .await?;
162
163    Ok(workday_id)
164}
165
166/// Replaces the day's pauses wholesale.
167///
168/// Pauses have no agent-side identity to match on - the agent splits and
169/// merges them as activity comes in - so the day's set is what was sent, and
170/// a pause the employee deleted disappears here too.
171async fn replace_pauses(tx: &mut Transaction<'_, Postgres>, workday_id: Uuid, pauses: &[PauseUpload]) -> Result<(), ApiError> {
172    sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
173        .bind(workday_id)
174        .execute(&mut **tx)
175        .await?;
176
177    for pause in pauses {
178        sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
179            .bind(workday_id)
180            .bind(pause.started_at.with_timezone(&Utc))
181            .bind(pause.ended_at.map(|at| at.with_timezone(&Utc)))
182            .bind(pause.duration_seconds)
183            .bind(pause.manual)
184            .bind(pause.reason.as_deref())
185            .execute(&mut **tx)
186            .await?;
187    }
188
189    Ok(())
190}
191
192/// Writes the day's tasks, correcting any the agent has sent before.
193///
194/// Tasks do carry an agent-side id, so they are matched rather than replaced:
195/// the same task may appear on several days, and wiping by date would take
196/// yesterday's copy with it.
197async fn upsert_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<usize, ApiError> {
198    for task in tasks {
199        sqlx::query(
200            "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
201             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
202             ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
203                 agent_group_id = EXCLUDED.agent_group_id,
204                 date = EXCLUDED.date,
205                 recorded_at = EXCLUDED.recorded_at,
206                 name = EXCLUDED.name,
207                 comment = EXCLUDED.comment,
208                 completeness = EXCLUDED.completeness",
209        )
210        .bind(user_id)
211        .bind(task.agent_task_id)
212        .bind(task.agent_group_id.unwrap_or(task.agent_task_id))
213        .bind(date)
214        .bind(task.recorded_at.with_timezone(&Utc))
215        .bind(task.name.trim())
216        .bind(task.comment.as_deref())
217        .bind(task.completeness)
218        .execute(&mut **tx)
219        .await?;
220    }
221
222    Ok(tasks.len())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn day_json(patch: serde_json::Value) -> DayUpload {
230        let mut value = serde_json::json!({
231            "date": "2026-08-14",
232            "started_at": "2026-08-14T09:00:00-03:00",
233            "pauses": [],
234            "tasks": [],
235        });
236        let (serde_json::Value::Object(base), serde_json::Value::Object(patch)) = (&mut value, patch) else {
237            panic!("both must be objects");
238        };
239        base.extend(patch);
240        serde_json::from_value(value).expect("the fixture should deserialize")
241    }
242
243    #[test]
244    fn an_offset_is_required_on_every_instant() {
245        // The whole point of the contract: bare wall-clock time, which is what
246        // kasl stores locally, must not parse.
247        let bare = serde_json::json!({
248            "date": "2026-08-14",
249            "started_at": "2026-08-14T09:00:00",
250        });
251        assert!(
252            serde_json::from_value::<DayUpload>(bare).is_err(),
253            "an instant without an offset must be rejected"
254        );
255    }
256
257    #[test]
258    fn the_offset_is_preserved_as_an_instant() {
259        let day = day_json(serde_json::json!({ "started_at": "2026-08-14T09:00:00-03:00" }));
260        assert_eq!(day.started_at.with_timezone(&Utc).to_rfc3339(), "2026-08-14T12:00:00+00:00");
261    }
262
263    #[test]
264    fn a_day_may_still_be_open() {
265        let day = day_json(serde_json::json!({}));
266        assert!(day.ended_at.is_none(), "a missing ended_at means the day is still running");
267        validate(&day).expect("an open day is valid");
268    }
269
270    #[test]
271    fn a_day_cannot_end_before_it_starts() {
272        let day = day_json(serde_json::json!({ "ended_at": "2026-08-14T08:00:00-03:00" }));
273        let error = validate(&day).expect_err("a backwards day must be refused");
274        assert_eq!(error.to_string(), "ended_at is before started_at");
275    }
276
277    #[test]
278    fn impossible_pauses_and_tasks_are_named_in_the_error() {
279        let day = day_json(serde_json::json!({
280            "pauses": [
281                {"started_at": "2026-08-14T10:00:00-03:00", "ended_at": "2026-08-14T10:20:00-03:00", "duration_seconds": 1200},
282                {"started_at": "2026-08-14T12:00:00-03:00", "duration_seconds": -1},
283            ],
284        }));
285        let error = validate(&day).expect_err("a negative duration must be refused");
286        assert!(
287            error.to_string().contains("pauses[1]"),
288            "the message should point at the offending element: {error}"
289        );
290
291        let day = day_json(serde_json::json!({
292            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Ship it", "completeness": 101}],
293        }));
294        let error = validate(&day).expect_err("completeness above 100 must be refused");
295        assert!(
296            error.to_string().contains("tasks[0]"),
297            "the message should point at the offending element: {error}"
298        );
299    }
300
301    #[test]
302    fn a_task_group_defaults_to_the_task_itself() {
303        let day = day_json(serde_json::json!({
304            "tasks": [{"agent_task_id": 7, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Write the ingest", "completeness": 60}],
305        }));
306        let task = &day.tasks[0];
307        assert_eq!(task.agent_group_id, None, "an absent group is absent on the wire");
308        assert_eq!(task.agent_group_id.unwrap_or(task.agent_task_id), 7, "and resolves to the task itself");
309    }
310
311    #[test]
312    fn a_nameless_task_is_refused() {
313        let day = day_json(serde_json::json!({
314            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "   ", "completeness": 50}],
315        }));
316        assert!(validate(&day).is_err(), "a task with a blank name carries no information");
317    }
318}