Skip to main content

kasl_server/
ingest.rs

1//! The upload endpoints: `POST /api/v1/days` and `/days/batch`.
2//!
3//! An agent sends a day (the workday, its pauses, and the tasks recorded on it)
4//! and the server writes it whole or not at all. After time offline it sends a
5//! stretch of them at once; every day in a batch travels the same path as a
6//! live one, and is written on its own so a day that cannot be accepted does
7//! not hold up the rest (ADR 0005).
8//!
9//! Two rules define the contract, both settled before a line of this was
10//! written:
11//!
12//! * **The agent is the source of truth.** A re-upload overwrites what the
13//!   server holds for that date. The employee edits their day in kasl - fixes
14//!   a task, adds a break they took - and the correction has to land. As a
15//!   consequence the same payload sent twice leaves the same rows, which is
16//!   what makes a retry after a lost connection safe.
17//! * **Timestamps carry an offset, and the day carries its own date.** kasl
18//!   stores bare wall-clock text; sending that as-is would make one team's
19//!   hours incomparable across time zones. See ADR 0003.
20
21use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
22use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
23use serde::{Deserialize, Serialize};
24use sqlx::{Postgres, Transaction};
25use uuid::Uuid;
26
27use crate::{app::AppState, auth::AuthenticatedAgent, error::ApiError};
28
29/// One day as the agent recorded it.
30#[derive(Debug, Deserialize)]
31pub struct DayUpload {
32    /// The employee's local calendar date, `YYYY-MM-DD`. Sent explicitly
33    /// rather than derived from `started_at`: which day work belongs to is the
34    /// agent's call, and near midnight the two disagree.
35    pub date: NaiveDate,
36    /// When the day started, with the agent's UTC offset.
37    pub started_at: DateTime<FixedOffset>,
38    /// When it ended; absent while the day is still open.
39    #[serde(default)]
40    pub ended_at: Option<DateTime<FixedOffset>>,
41    #[serde(default)]
42    pub pauses: Vec<PauseUpload>,
43    #[serde(default)]
44    pub tasks: Vec<TaskUpload>,
45    /// Whether `tasks` is everything the agent holds for this date.
46    ///
47    /// When set, a task the server stored on this date and the agent no longer
48    /// sends is one the employee deleted, and it is removed here too. Tasks on
49    /// other dates are untouched - they are matched by id and outlive a single
50    /// day, so wiping by date alone would take yesterday's copy with it.
51    ///
52    /// Defaults to false: an older agent, which cannot know about this flag,
53    /// must never have its silence read as "delete the rest".
54    #[serde(default)]
55    pub tasks_are_complete: bool,
56}
57
58#[derive(Debug, Deserialize)]
59pub struct PauseUpload {
60    pub started_at: DateTime<FixedOffset>,
61    #[serde(default)]
62    pub ended_at: Option<DateTime<FixedOffset>>,
63    /// Seconds. The agent merges neighbouring pauses before sending, so this
64    /// is not always `ended_at - started_at` and is taken as given.
65    #[serde(default)]
66    pub duration_seconds: Option<i32>,
67    /// A break the employee entered by hand (the agent's `protected` flag).
68    #[serde(default)]
69    pub manual: bool,
70    #[serde(default)]
71    pub reason: Option<String>,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct TaskUpload {
76    /// The agent's own row id. The key a re-upload matches on, so a corrected
77    /// task updates instead of piling up.
78    pub agent_task_id: i32,
79    /// The agent's `task_id`: the same work carried across several days.
80    /// Defaults to `agent_task_id`, which is what the agent stores for a task
81    /// started today.
82    #[serde(default)]
83    pub agent_group_id: Option<i32>,
84    pub recorded_at: DateTime<FixedOffset>,
85    pub name: String,
86    #[serde(default)]
87    pub comment: Option<String>,
88    /// Percent complete, 0..=100.
89    pub completeness: i16,
90}
91
92/// What the agent gets back: enough to log, and to notice a silent no-op.
93#[derive(Debug, Serialize)]
94pub struct DayAccepted {
95    pub workday_id: Uuid,
96    pub date: NaiveDate,
97    pub pauses: usize,
98    pub tasks: usize,
99    /// Tasks dropped because the agent declared its set authoritative. Zero on
100    /// the common upload; a non-zero count is worth noticing in a log.
101    pub deleted_tasks: u64,
102}
103
104/// A stretch of days at once - what an agent sends after time offline.
105#[derive(Debug, Deserialize)]
106pub struct BatchUpload {
107    pub days: Vec<DayUpload>,
108}
109
110/// What came of a batch. Counts first: a caller that reads only the status sees
111/// `200` even when a day was refused, so the summary has to be impossible to
112/// miss in the body.
113#[derive(Debug, Serialize)]
114pub struct BatchResult {
115    pub accepted: usize,
116    pub rejected: usize,
117    pub results: Vec<DayResult>,
118}
119
120/// One day's fate, in the order the days were sent.
121#[derive(Debug, Serialize)]
122#[serde(tag = "status", rename_all = "lowercase")]
123pub enum DayResult {
124    Accepted {
125        #[serde(flatten)]
126        day: DayAccepted,
127    },
128    /// The date is echoed even here: it is how the agent knows which of its
129    /// pending days to keep, and a day can be refused before anything else
130    /// about it is known to be usable.
131    Rejected { date: NaiveDate, error: String },
132}
133
134/// Accepts one day from an authenticated agent.
135pub async fn upload_day(State(state): State<AppState>, agent: AuthenticatedAgent, Json(day): Json<DayUpload>) -> Result<impl IntoResponse, ApiError> {
136    let accepted = store_day(&state.pool, agent, &day).await?;
137    Ok((StatusCode::OK, Json(accepted)))
138}
139
140/// Accepts a backlog of days, each written on its own.
141///
142/// One bad day does not sink the batch. An agent holding a day the server will
143/// never accept would otherwise be unable to deliver any of its backlog, and
144/// would retry the same doomed request forever (ADR 0005).
145pub async fn upload_batch(State(state): State<AppState>, agent: AuthenticatedAgent, Json(batch): Json<BatchUpload>) -> Result<impl IntoResponse, ApiError> {
146    if batch.days.len() > state.max_batch_days {
147        return Err(ApiError::new(
148            StatusCode::PAYLOAD_TOO_LARGE,
149            format!("a batch carries at most {} days; split the backlog", state.max_batch_days),
150        ));
151    }
152
153    let mut results = Vec::with_capacity(batch.days.len());
154    let mut accepted = 0;
155    let mut rejected = 0;
156
157    for day in &batch.days {
158        match store_day(&state.pool, agent, day).await {
159            Ok(stored) => {
160                accepted += 1;
161                results.push(DayResult::Accepted { day: stored });
162            }
163            // A day the server itself failed on aborts the batch: the agent
164            // must retry it, and reporting a database outage as "this day is
165            // rejected" would tell it to give up instead.
166            Err(error) if error.status().is_server_error() => return Err(error),
167            Err(error) => {
168                rejected += 1;
169                results.push(DayResult::Rejected {
170                    date: day.date,
171                    error: error.to_string(),
172                });
173            }
174        }
175    }
176
177    tracing::info!(user_id = %agent.user_id, agent_id = %agent.agent_id, accepted, rejected, "accepted a batch");
178
179    Ok((StatusCode::OK, Json(BatchResult { accepted, rejected, results })))
180}
181
182/// Writes one day, whole or not at all.
183///
184/// Shared by the single-day route and the batch one so a backfilled day is
185/// stored by exactly the same code as a live one.
186async fn store_day(pool: &sqlx::PgPool, agent: AuthenticatedAgent, day: &DayUpload) -> Result<DayAccepted, ApiError> {
187    validate(day)?;
188
189    // All of it or none: a day whose pauses landed but whose tasks did not
190    // would show up on a dashboard as real, and nobody would know to re-send.
191    let mut tx = pool.begin().await?;
192
193    let workday_id = upsert_workday(&mut tx, agent.user_id, day).await?;
194    replace_pauses(&mut tx, workday_id, &day.pauses).await?;
195    let tasks = upsert_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?;
196    let deleted_tasks = if day.tasks_are_complete {
197        delete_missing_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?
198    } else {
199        0
200    };
201
202    tx.commit().await?;
203
204    // The agent, not just the person: several machines report for one employee
205    // and "which one sent this" is the first question when a day looks wrong.
206    tracing::info!(%workday_id, user_id = %agent.user_id, agent_id = %agent.agent_id, date = %day.date, pauses = day.pauses.len(), tasks, deleted_tasks, "accepted a day");
207
208    Ok(DayAccepted {
209        workday_id,
210        date: day.date,
211        pauses: day.pauses.len(),
212        tasks,
213        deleted_tasks,
214    })
215}
216
217/// Rejects payloads the schema would refuse anyway, with a message that says
218/// which field is wrong - a constraint violation surfaces as a 500 and tells
219/// the agent nothing it can act on.
220fn validate(day: &DayUpload) -> Result<(), ApiError> {
221    if let Some(ended_at) = day.ended_at
222        && ended_at < day.started_at
223    {
224        return Err(ApiError::bad_request("ended_at is before started_at"));
225    }
226
227    for (index, pause) in day.pauses.iter().enumerate() {
228        if let Some(ended_at) = pause.ended_at
229            && ended_at < pause.started_at
230        {
231            return Err(ApiError::bad_request(format!("pauses[{index}]: ended_at is before started_at")));
232        }
233        if pause.duration_seconds.is_some_and(|seconds| seconds < 0) {
234            return Err(ApiError::bad_request(format!("pauses[{index}]: duration_seconds is negative")));
235        }
236    }
237
238    for (index, task) in day.tasks.iter().enumerate() {
239        if !(0..=100).contains(&task.completeness) {
240            return Err(ApiError::bad_request(format!("tasks[{index}]: completeness must be between 0 and 100")));
241        }
242        if task.name.trim().is_empty() {
243            return Err(ApiError::bad_request(format!("tasks[{index}]: name is empty")));
244        }
245    }
246
247    Ok(())
248}
249
250/// Writes the day, or corrects the one already stored for that date.
251async fn upsert_workday(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, day: &DayUpload) -> Result<Uuid, ApiError> {
252    let workday_id: Uuid = sqlx::query_scalar(
253        "INSERT INTO workdays (user_id, date, started_at, ended_at) VALUES ($1, $2, $3, $4)
254         ON CONFLICT (user_id, date) DO UPDATE SET started_at = EXCLUDED.started_at, ended_at = EXCLUDED.ended_at
255         RETURNING id",
256    )
257    .bind(user_id)
258    .bind(day.date)
259    .bind(day.started_at.with_timezone(&Utc))
260    .bind(day.ended_at.map(|at| at.with_timezone(&Utc)))
261    .fetch_one(&mut **tx)
262    .await?;
263
264    Ok(workday_id)
265}
266
267/// Replaces the day's pauses wholesale.
268///
269/// Pauses have no agent-side identity to match on - the agent splits and
270/// merges them as activity comes in - so the day's set is what was sent, and
271/// a pause the employee deleted disappears here too.
272async fn replace_pauses(tx: &mut Transaction<'_, Postgres>, workday_id: Uuid, pauses: &[PauseUpload]) -> Result<(), ApiError> {
273    sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
274        .bind(workday_id)
275        .execute(&mut **tx)
276        .await?;
277
278    for pause in pauses {
279        sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
280            .bind(workday_id)
281            .bind(pause.started_at.with_timezone(&Utc))
282            .bind(pause.ended_at.map(|at| at.with_timezone(&Utc)))
283            .bind(pause.duration_seconds)
284            .bind(pause.manual)
285            .bind(pause.reason.as_deref())
286            .execute(&mut **tx)
287            .await?;
288    }
289
290    Ok(())
291}
292
293/// Writes the day's tasks, correcting any the agent has sent before.
294///
295/// Tasks do carry an agent-side id, so they are matched rather than replaced:
296/// the same task may appear on several days, and wiping by date would take
297/// yesterday's copy with it.
298async fn upsert_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<usize, ApiError> {
299    for task in tasks {
300        sqlx::query(
301            "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
302             VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
303             ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
304                 agent_group_id = EXCLUDED.agent_group_id,
305                 date = EXCLUDED.date,
306                 recorded_at = EXCLUDED.recorded_at,
307                 name = EXCLUDED.name,
308                 comment = EXCLUDED.comment,
309                 completeness = EXCLUDED.completeness",
310        )
311        .bind(user_id)
312        .bind(task.agent_task_id)
313        .bind(task.agent_group_id.unwrap_or(task.agent_task_id))
314        .bind(date)
315        .bind(task.recorded_at.with_timezone(&Utc))
316        .bind(task.name.trim())
317        .bind(task.comment.as_deref())
318        .bind(task.completeness)
319        .execute(&mut **tx)
320        .await?;
321    }
322
323    Ok(tasks.len())
324}
325
326/// Removes the date's tasks the agent did not send.
327///
328/// Only reached when the agent marked its list authoritative. Scoped to the
329/// one date on purpose: a task carried across several days keeps its rows on
330/// the others, and an agent backfilling Monday cannot erase Friday.
331async fn delete_missing_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<u64, ApiError> {
332    let kept: Vec<i32> = tasks.iter().map(|task| task.agent_task_id).collect();
333
334    let deleted = sqlx::query("DELETE FROM tasks WHERE user_id = $1 AND date = $2 AND agent_task_id <> ALL($3)")
335        .bind(user_id)
336        .bind(date)
337        .bind(&kept)
338        .execute(&mut **tx)
339        .await?
340        .rows_affected();
341
342    Ok(deleted)
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn day_json(patch: serde_json::Value) -> DayUpload {
350        let mut value = serde_json::json!({
351            "date": "2026-08-14",
352            "started_at": "2026-08-14T09:00:00-03:00",
353            "pauses": [],
354            "tasks": [],
355        });
356        let (serde_json::Value::Object(base), serde_json::Value::Object(patch)) = (&mut value, patch) else {
357            panic!("both must be objects");
358        };
359        base.extend(patch);
360        serde_json::from_value(value).expect("the fixture should deserialize")
361    }
362
363    #[test]
364    fn an_offset_is_required_on_every_instant() {
365        // The whole point of the contract: bare wall-clock time, which is what
366        // kasl stores locally, must not parse.
367        let bare = serde_json::json!({
368            "date": "2026-08-14",
369            "started_at": "2026-08-14T09:00:00",
370        });
371        assert!(
372            serde_json::from_value::<DayUpload>(bare).is_err(),
373            "an instant without an offset must be rejected"
374        );
375    }
376
377    #[test]
378    fn the_offset_is_preserved_as_an_instant() {
379        let day = day_json(serde_json::json!({ "started_at": "2026-08-14T09:00:00-03:00" }));
380        assert_eq!(day.started_at.with_timezone(&Utc).to_rfc3339(), "2026-08-14T12:00:00+00:00");
381    }
382
383    #[test]
384    fn a_day_may_still_be_open() {
385        let day = day_json(serde_json::json!({}));
386        assert!(day.ended_at.is_none(), "a missing ended_at means the day is still running");
387        validate(&day).expect("an open day is valid");
388    }
389
390    #[test]
391    fn a_day_cannot_end_before_it_starts() {
392        let day = day_json(serde_json::json!({ "ended_at": "2026-08-14T08:00:00-03:00" }));
393        let error = validate(&day).expect_err("a backwards day must be refused");
394        assert_eq!(error.to_string(), "ended_at is before started_at");
395    }
396
397    #[test]
398    fn impossible_pauses_and_tasks_are_named_in_the_error() {
399        let day = day_json(serde_json::json!({
400            "pauses": [
401                {"started_at": "2026-08-14T10:00:00-03:00", "ended_at": "2026-08-14T10:20:00-03:00", "duration_seconds": 1200},
402                {"started_at": "2026-08-14T12:00:00-03:00", "duration_seconds": -1},
403            ],
404        }));
405        let error = validate(&day).expect_err("a negative duration must be refused");
406        assert!(
407            error.to_string().contains("pauses[1]"),
408            "the message should point at the offending element: {error}"
409        );
410
411        let day = day_json(serde_json::json!({
412            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Ship it", "completeness": 101}],
413        }));
414        let error = validate(&day).expect_err("completeness above 100 must be refused");
415        assert!(
416            error.to_string().contains("tasks[0]"),
417            "the message should point at the offending element: {error}"
418        );
419    }
420
421    #[test]
422    fn a_task_group_defaults_to_the_task_itself() {
423        let day = day_json(serde_json::json!({
424            "tasks": [{"agent_task_id": 7, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Write the ingest", "completeness": 60}],
425        }));
426        let task = &day.tasks[0];
427        assert_eq!(task.agent_group_id, None, "an absent group is absent on the wire");
428        assert_eq!(task.agent_group_id.unwrap_or(task.agent_task_id), 7, "and resolves to the task itself");
429    }
430
431    #[test]
432    fn an_agent_that_says_nothing_deletes_nothing() {
433        // The compatibility hinge: agents shipped before this flag existed send
434        // whatever tasks they have, and their silence must not be read as
435        // "delete everything else on that date".
436        let day = day_json(serde_json::json!({}));
437        assert!(!day.tasks_are_complete, "the authoritative set must be opt-in");
438
439        let day = day_json(serde_json::json!({ "tasks_are_complete": true }));
440        assert!(day.tasks_are_complete, "and an agent that opts in is heard");
441    }
442
443    #[test]
444    fn a_nameless_task_is_refused() {
445        let day = day_json(serde_json::json!({
446            "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "   ", "completeness": 50}],
447        }));
448        assert!(validate(&day).is_err(), "a task with a blank name carries no information");
449    }
450}