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::{app::AppState, auth::AuthenticatedAgent, error::ApiError};
28
29#[derive(Debug, Deserialize)]
31pub struct DayUpload {
32 pub date: NaiveDate,
36 pub started_at: DateTime<FixedOffset>,
38 #[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 #[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 #[serde(default)]
66 pub duration_seconds: Option<i32>,
67 #[serde(default)]
69 pub manual: bool,
70 #[serde(default)]
71 pub reason: Option<String>,
72}
73
74#[derive(Debug, Deserialize)]
75pub struct TaskUpload {
76 pub agent_task_id: i32,
79 #[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 pub completeness: i16,
90}
91
92#[derive(Debug, Serialize)]
94pub struct DayAccepted {
95 pub workday_id: Uuid,
96 pub date: NaiveDate,
97 pub pauses: usize,
98 pub tasks: usize,
99 pub deleted_tasks: u64,
102}
103
104#[derive(Debug, Deserialize)]
106pub struct BatchUpload {
107 pub days: Vec<DayUpload>,
108}
109
110#[derive(Debug, Serialize)]
114pub struct BatchResult {
115 pub accepted: usize,
116 pub rejected: usize,
117 pub results: Vec<DayResult>,
118}
119
120#[derive(Debug, Serialize)]
122#[serde(tag = "status", rename_all = "lowercase")]
123pub enum DayResult {
124 Accepted {
125 #[serde(flatten)]
126 day: DayAccepted,
127 },
128 Rejected { date: NaiveDate, error: String },
132}
133
134pub 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
140pub 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 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
182async fn store_day(pool: &sqlx::PgPool, agent: AuthenticatedAgent, day: &DayUpload) -> Result<DayAccepted, ApiError> {
187 validate(day)?;
188
189 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 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
217fn 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
250async 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
267async 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
293async 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
326async 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 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 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}