1use 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#[derive(Debug, Deserialize)]
28pub struct DayUpload {
29 pub date: NaiveDate,
33 pub started_at: DateTime<FixedOffset>,
35 #[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 #[serde(default)]
52 pub duration_seconds: Option<i32>,
53 #[serde(default)]
55 pub manual: bool,
56 #[serde(default)]
57 pub reason: Option<String>,
58}
59
60#[derive(Debug, Deserialize)]
61pub struct TaskUpload {
62 pub agent_task_id: i32,
65 #[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 pub completeness: i16,
76}
77
78#[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
87pub async fn upload_day(State(state): State<AppState>, agent: AuthenticatedAgent, Json(day): Json<DayUpload>) -> Result<impl IntoResponse, ApiError> {
89 validate(&day)?;
90
91 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 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
116fn 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
149async 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
166async 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
192async 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 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}