1use 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::{
28 app::AppState,
29 auth::AuthenticatedAgent,
30 calendar::WorkdayKind,
31 error::ApiError,
32 privacy::{Dropped, Policy, PrivacyLevel},
33};
34
35#[derive(Debug, Deserialize)]
37pub struct DayUpload {
38 pub date: NaiveDate,
42 pub started_at: DateTime<FixedOffset>,
44 #[serde(default)]
46 pub ended_at: Option<DateTime<FixedOffset>>,
47 #[serde(default)]
48 pub pauses: Vec<PauseUpload>,
49 #[serde(default)]
50 pub tasks: Vec<TaskUpload>,
51 #[serde(default)]
61 pub tasks_are_complete: bool,
62 #[serde(default)]
70 pub kind: WorkdayKind,
71}
72
73#[derive(Debug, Deserialize)]
74pub struct PauseUpload {
75 pub started_at: DateTime<FixedOffset>,
76 #[serde(default)]
77 pub ended_at: Option<DateTime<FixedOffset>>,
78 #[serde(default)]
81 pub duration_seconds: Option<i32>,
82 #[serde(default)]
84 pub manual: bool,
85 #[serde(default)]
86 pub reason: Option<String>,
87}
88
89#[derive(Debug, Deserialize)]
90pub struct TaskUpload {
91 pub agent_task_id: i32,
94 #[serde(default)]
98 pub agent_group_id: Option<i32>,
99 pub recorded_at: DateTime<FixedOffset>,
100 pub name: String,
101 #[serde(default)]
102 pub comment: Option<String>,
103 pub completeness: i16,
105}
106
107#[derive(Debug, Serialize)]
109pub struct DayAccepted {
110 pub workday_id: Uuid,
111 pub date: NaiveDate,
112 pub kind: WorkdayKind,
116 pub pauses: usize,
117 pub tasks: usize,
118 pub deleted_tasks: u64,
121 pub privacy_level: PrivacyLevel,
125 #[serde(skip_serializing_if = "Dropped::is_empty")]
128 pub discarded: Dropped,
129}
130
131#[derive(Debug, Deserialize)]
133pub struct BatchUpload {
134 pub days: Vec<DayUpload>,
135}
136
137#[derive(Debug, Serialize)]
141pub struct BatchResult {
142 pub accepted: usize,
143 pub rejected: usize,
144 pub results: Vec<DayResult>,
145}
146
147#[derive(Debug, Serialize)]
149#[serde(tag = "status", rename_all = "lowercase")]
150pub enum DayResult {
151 Accepted {
152 #[serde(flatten)]
153 day: DayAccepted,
154 },
155 Rejected { date: NaiveDate, error: String },
159}
160
161pub async fn upload_day(State(state): State<AppState>, agent: AuthenticatedAgent, Json(day): Json<DayUpload>) -> Result<impl IntoResponse, ApiError> {
163 let policy = Policy::load(&state.pool).await?;
164 let accepted = store_day(&state.pool, agent, &day, policy).await?;
165 Ok((StatusCode::OK, Json(accepted)))
166}
167
168pub async fn upload_batch(State(state): State<AppState>, agent: AuthenticatedAgent, Json(batch): Json<BatchUpload>) -> Result<impl IntoResponse, ApiError> {
174 if batch.days.len() > state.max_batch_days {
175 return Err(ApiError::new(
176 StatusCode::PAYLOAD_TOO_LARGE,
177 format!("a batch carries at most {} days; split the backlog", state.max_batch_days),
178 ));
179 }
180
181 let policy = Policy::load(&state.pool).await?;
185
186 let mut results = Vec::with_capacity(batch.days.len());
187 let mut accepted = 0;
188 let mut rejected = 0;
189
190 for day in &batch.days {
191 match store_day(&state.pool, agent, day, policy).await {
192 Ok(stored) => {
193 accepted += 1;
194 results.push(DayResult::Accepted { day: stored });
195 }
196 Err(error) if error.status().is_server_error() => return Err(error),
200 Err(error) => {
201 rejected += 1;
202 results.push(DayResult::Rejected {
203 date: day.date,
204 error: error.to_string(),
205 });
206 }
207 }
208 }
209
210 tracing::info!(user_id = %agent.user_id, agent_id = %agent.agent_id, accepted, rejected, "accepted a batch");
211
212 Ok((StatusCode::OK, Json(BatchResult { accepted, rejected, results })))
213}
214
215async fn store_day(pool: &sqlx::PgPool, agent: AuthenticatedAgent, day: &DayUpload, policy: Policy) -> Result<DayAccepted, ApiError> {
220 validate(day)?;
221
222 let level = policy.level();
226 let (day, discarded, pause_totals) = filter(day, level);
227 let day = &day;
228
229 let mut tx = pool.begin().await?;
232
233 let workday_id = upsert_workday(&mut tx, agent.user_id, day, pause_totals).await?;
234 replace_pauses(&mut tx, workday_id, &day.pauses).await?;
235 let tasks = upsert_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?;
236 let deleted_tasks = if day.tasks_are_complete || !level.keeps_tasks() {
242 delete_missing_tasks(&mut tx, agent.user_id, day.date, &day.tasks).await?
243 } else {
244 0
245 };
246
247 tx.commit().await?;
248
249 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");
252
253 Ok(DayAccepted {
254 workday_id,
255 date: day.date,
256 kind: day.kind,
257 pauses: day.pauses.len(),
258 tasks,
259 deleted_tasks,
260 privacy_level: level,
261 discarded,
262 })
263}
264
265fn filter(day: &DayUpload, level: PrivacyLevel) -> (DayUpload, Dropped, Option<PauseTotals>) {
277 let mut discarded = Dropped::default();
278
279 let pauses = if level.keeps_pause_times() {
280 day.pauses
281 .iter()
282 .map(|pause| PauseUpload {
283 started_at: pause.started_at,
284 ended_at: pause.ended_at,
285 duration_seconds: pause.duration_seconds,
286 manual: pause.manual,
287 reason: match (&pause.reason, level.keeps_free_text()) {
288 (Some(reason), false) if !reason.is_empty() => {
289 discarded.free_text += 1;
290 None
291 }
292 (reason, true) => reason.clone(),
293 _ => None,
294 },
295 })
296 .collect()
297 } else {
298 discarded.pauses = day.pauses.len();
302 Vec::new()
303 };
304
305 let tasks = if level.keeps_tasks() {
306 day.tasks
307 .iter()
308 .map(|task| TaskUpload {
309 agent_task_id: task.agent_task_id,
310 agent_group_id: task.agent_group_id,
311 recorded_at: task.recorded_at,
312 name: task.name.clone(),
313 comment: match (&task.comment, level.keeps_free_text()) {
314 (Some(comment), false) if !comment.is_empty() => {
315 discarded.free_text += 1;
316 None
317 }
318 (comment, true) => comment.clone(),
319 _ => None,
320 },
321 completeness: task.completeness,
322 })
323 .collect()
324 } else {
325 discarded.tasks = day.tasks.len();
326 Vec::new()
327 };
328
329 let filtered = DayUpload {
335 date: day.date,
336 started_at: day.started_at,
337 ended_at: day.ended_at,
338 pauses,
339 tasks,
340 tasks_are_complete: day.tasks_are_complete,
341 kind: day.kind,
346 };
347
348 let totals = (!level.keeps_pause_times()).then(|| pause_totals(&day.pauses));
349
350 (filtered, discarded, totals)
351}
352
353#[derive(Debug, Clone, Copy, PartialEq, Eq)]
355struct PauseTotals {
356 count: i32,
357 seconds: i32,
358}
359
360fn pause_totals(pauses: &[PauseUpload]) -> PauseTotals {
367 let count = pauses.len() as i32;
368 let seconds = pauses
369 .iter()
370 .map(|pause| {
371 pause.duration_seconds.unwrap_or_else(|| {
372 pause
376 .ended_at
377 .map(|ended| (ended - pause.started_at).num_seconds().clamp(0, i32::MAX as i64) as i32)
378 .unwrap_or(0)
379 })
380 })
381 .fold(0i32, |total, seconds| total.saturating_add(seconds));
382
383 PauseTotals { count, seconds }
384}
385
386fn validate(day: &DayUpload) -> Result<(), ApiError> {
390 if let Some(ended_at) = day.ended_at
391 && ended_at < day.started_at
392 {
393 return Err(ApiError::bad_request("ended_at is before started_at"));
394 }
395
396 for (index, pause) in day.pauses.iter().enumerate() {
397 if let Some(ended_at) = pause.ended_at
398 && ended_at < pause.started_at
399 {
400 return Err(ApiError::bad_request(format!("pauses[{index}]: ended_at is before started_at")));
401 }
402 if pause.duration_seconds.is_some_and(|seconds| seconds < 0) {
403 return Err(ApiError::bad_request(format!("pauses[{index}]: duration_seconds is negative")));
404 }
405 }
406
407 for (index, task) in day.tasks.iter().enumerate() {
408 if !(0..=100).contains(&task.completeness) {
409 return Err(ApiError::bad_request(format!("tasks[{index}]: completeness must be between 0 and 100")));
410 }
411 if task.name.trim().is_empty() {
412 return Err(ApiError::bad_request(format!("tasks[{index}]: name is empty")));
413 }
414 }
415
416 Ok(())
417}
418
419async fn upsert_workday(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, day: &DayUpload, pause_totals: Option<PauseTotals>) -> Result<Uuid, ApiError> {
425 let workday_id: Uuid = sqlx::query_scalar(
426 "INSERT INTO workdays (user_id, date, started_at, ended_at, paused_count, paused_seconds, kind) VALUES ($1, $2, $3, $4, $5, $6, $7)
427 ON CONFLICT (user_id, date) DO UPDATE SET
428 started_at = EXCLUDED.started_at,
429 ended_at = EXCLUDED.ended_at,
430 paused_count = EXCLUDED.paused_count,
431 paused_seconds = EXCLUDED.paused_seconds,
432 kind = EXCLUDED.kind
433 RETURNING id",
434 )
435 .bind(user_id)
436 .bind(day.date)
437 .bind(day.started_at.with_timezone(&Utc))
438 .bind(day.ended_at.map(|at| at.with_timezone(&Utc)))
439 .bind(pause_totals.map(|totals| totals.count))
440 .bind(pause_totals.map(|totals| totals.seconds))
441 .bind(day.kind)
442 .fetch_one(&mut **tx)
443 .await?;
444
445 Ok(workday_id)
446}
447
448async fn replace_pauses(tx: &mut Transaction<'_, Postgres>, workday_id: Uuid, pauses: &[PauseUpload]) -> Result<(), ApiError> {
454 sqlx::query("DELETE FROM pauses WHERE workday_id = $1")
455 .bind(workday_id)
456 .execute(&mut **tx)
457 .await?;
458
459 for pause in pauses {
460 sqlx::query("INSERT INTO pauses (workday_id, started_at, ended_at, duration_seconds, manual, reason) VALUES ($1, $2, $3, $4, $5, $6)")
461 .bind(workday_id)
462 .bind(pause.started_at.with_timezone(&Utc))
463 .bind(pause.ended_at.map(|at| at.with_timezone(&Utc)))
464 .bind(pause.duration_seconds)
465 .bind(pause.manual)
466 .bind(pause.reason.as_deref())
467 .execute(&mut **tx)
468 .await?;
469 }
470
471 Ok(())
472}
473
474async fn upsert_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<usize, ApiError> {
480 for task in tasks {
481 sqlx::query(
482 "INSERT INTO tasks (user_id, agent_task_id, agent_group_id, date, recorded_at, name, comment, completeness)
483 VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
484 ON CONFLICT (user_id, agent_task_id) DO UPDATE SET
485 agent_group_id = EXCLUDED.agent_group_id,
486 date = EXCLUDED.date,
487 recorded_at = EXCLUDED.recorded_at,
488 name = EXCLUDED.name,
489 comment = EXCLUDED.comment,
490 completeness = EXCLUDED.completeness",
491 )
492 .bind(user_id)
493 .bind(task.agent_task_id)
494 .bind(task.agent_group_id.unwrap_or(task.agent_task_id))
495 .bind(date)
496 .bind(task.recorded_at.with_timezone(&Utc))
497 .bind(task.name.trim())
498 .bind(task.comment.as_deref())
499 .bind(task.completeness)
500 .execute(&mut **tx)
501 .await?;
502 }
503
504 Ok(tasks.len())
505}
506
507async fn delete_missing_tasks(tx: &mut Transaction<'_, Postgres>, user_id: Uuid, date: NaiveDate, tasks: &[TaskUpload]) -> Result<u64, ApiError> {
513 let kept: Vec<i32> = tasks.iter().map(|task| task.agent_task_id).collect();
514
515 let deleted = sqlx::query("DELETE FROM tasks WHERE user_id = $1 AND date = $2 AND agent_task_id <> ALL($3)")
516 .bind(user_id)
517 .bind(date)
518 .bind(&kept)
519 .execute(&mut **tx)
520 .await?
521 .rows_affected();
522
523 Ok(deleted)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 fn day_json(patch: serde_json::Value) -> DayUpload {
531 let mut value = serde_json::json!({
532 "date": "2026-08-14",
533 "started_at": "2026-08-14T09:00:00-03:00",
534 "pauses": [],
535 "tasks": [],
536 });
537 let (serde_json::Value::Object(base), serde_json::Value::Object(patch)) = (&mut value, patch) else {
538 panic!("both must be objects");
539 };
540 base.extend(patch);
541 serde_json::from_value(value).expect("the fixture should deserialize")
542 }
543
544 #[test]
545 fn an_offset_is_required_on_every_instant() {
546 let bare = serde_json::json!({
549 "date": "2026-08-14",
550 "started_at": "2026-08-14T09:00:00",
551 });
552 assert!(
553 serde_json::from_value::<DayUpload>(bare).is_err(),
554 "an instant without an offset must be rejected"
555 );
556 }
557
558 #[test]
559 fn the_offset_is_preserved_as_an_instant() {
560 let day = day_json(serde_json::json!({ "started_at": "2026-08-14T09:00:00-03:00" }));
561 assert_eq!(day.started_at.with_timezone(&Utc).to_rfc3339(), "2026-08-14T12:00:00+00:00");
562 }
563
564 #[test]
565 fn a_day_may_still_be_open() {
566 let day = day_json(serde_json::json!({}));
567 assert!(day.ended_at.is_none(), "a missing ended_at means the day is still running");
568 validate(&day).expect("an open day is valid");
569 }
570
571 #[test]
572 fn a_day_cannot_end_before_it_starts() {
573 let day = day_json(serde_json::json!({ "ended_at": "2026-08-14T08:00:00-03:00" }));
574 let error = validate(&day).expect_err("a backwards day must be refused");
575 assert_eq!(error.to_string(), "ended_at is before started_at");
576 }
577
578 #[test]
579 fn impossible_pauses_and_tasks_are_named_in_the_error() {
580 let day = day_json(serde_json::json!({
581 "pauses": [
582 {"started_at": "2026-08-14T10:00:00-03:00", "ended_at": "2026-08-14T10:20:00-03:00", "duration_seconds": 1200},
583 {"started_at": "2026-08-14T12:00:00-03:00", "duration_seconds": -1},
584 ],
585 }));
586 let error = validate(&day).expect_err("a negative duration must be refused");
587 assert!(
588 error.to_string().contains("pauses[1]"),
589 "the message should point at the offending element: {error}"
590 );
591
592 let day = day_json(serde_json::json!({
593 "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Ship it", "completeness": 101}],
594 }));
595 let error = validate(&day).expect_err("completeness above 100 must be refused");
596 assert!(
597 error.to_string().contains("tasks[0]"),
598 "the message should point at the offending element: {error}"
599 );
600 }
601
602 #[test]
603 fn a_task_group_defaults_to_the_task_itself() {
604 let day = day_json(serde_json::json!({
605 "tasks": [{"agent_task_id": 7, "recorded_at": "2026-08-14T17:00:00-03:00", "name": "Write the ingest", "completeness": 60}],
606 }));
607 let task = &day.tasks[0];
608 assert_eq!(task.agent_group_id, None, "an absent group is absent on the wire");
609 assert_eq!(task.agent_group_id.unwrap_or(task.agent_task_id), 7, "and resolves to the task itself");
610 }
611
612 #[test]
613 fn an_agent_that_says_nothing_deletes_nothing() {
614 let day = day_json(serde_json::json!({}));
618 assert!(!day.tasks_are_complete, "the authoritative set must be opt-in");
619
620 let day = day_json(serde_json::json!({ "tasks_are_complete": true }));
621 assert!(day.tasks_are_complete, "and an agent that opts in is heard");
622 }
623
624 #[test]
625 fn a_day_without_a_kind_is_a_worked_day() {
626 let day = day_json(serde_json::json!({}));
631 assert_eq!(day.kind, WorkdayKind::Work);
632
633 let day = day_json(serde_json::json!({ "kind": "vacation" }));
634 assert_eq!(day.kind, WorkdayKind::Vacation, "and an agent that says so is heard");
635 }
636
637 #[test]
638 fn a_nameless_task_is_refused() {
639 let day = day_json(serde_json::json!({
640 "tasks": [{"agent_task_id": 1, "recorded_at": "2026-08-14T17:00:00-03:00", "name": " ", "completeness": 50}],
641 }));
642 assert!(validate(&day).is_err(), "a task with a blank name carries no information");
643 }
644}