//! GET /api/oneliners — list oneliners for the authenticated user. //! //! Time window is caller-controlled via `?hours=N` (default 16, clamped //! to [1, MAX_HOURS]). Each entry carries the case-id, the oneliner //! text (or null if not yet generated), and UTC timestamps for when the //! case started and when its oneliner was last written. //! //! Conditional-GET: the response carries a weak ETag `W/"-"` //! derived from the per-user watermark. Clients that send back a matching //! `If-None-Match` get `304 Not Modified` without touching the filesystem. //! The `hours` component is included so a client swapping window sizes //! does not get a stale 304. use std::path::{Path, PathBuf}; use std::time::SystemTime; use axum::extract::{Query, State}; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; use doctate_common::oneliners::{OnelinerEntry, OnelinersResponse}; use serde::Deserialize; use time::format_description::well_known::Rfc3339; use time::{Duration, OffsetDateTime}; use tracing::warn; use crate::auth::AuthenticatedUser; use crate::error::AppError; use crate::OnelinerWatermark; const DEFAULT_HOURS: u32 = 16; const MAX_HOURS: u32 = 168; #[derive(Deserialize)] pub struct OnelinersQuery { hours: Option, } pub async fn handle_oneliners( user: AuthenticatedUser, State(watermark): State, Query(params): Query, headers: HeaderMap, ) -> Result { let hours = params.hours.unwrap_or(DEFAULT_HOURS).clamp(1, MAX_HOURS); let now = OffsetDateTime::now_utc(); let since = now - Duration::hours(hours as i64); let current_watermark = watermark.read().await.get(&user.slug).copied(); let etag = build_etag(current_watermark, hours); if client_has_matching_etag(&headers, &etag) { return Ok(not_modified(&etag)); } let oneliners = enumerate_recent_oneliners(&user.data_dir, since).await; let body = OnelinersResponse { as_of: now.format(&Rfc3339).unwrap_or_default(), window_hours: hours, oneliners, }; let mut response = Json(body).into_response(); if let Ok(value) = etag.parse() { response.headers_mut().insert(header::ETAG, value); } Ok(response) } fn build_etag(watermark: Option, hours: u32) -> String { let seconds = watermark.map(|t| t.unix_timestamp()).unwrap_or(0); format!("W/\"{seconds}-{hours}\"") } /// Case-sensitive match against the raw `If-None-Match` header value. /// No wildcard or comma-split logic — the client is our own code sending /// back the exact ETag we handed it. fn client_has_matching_etag(headers: &HeaderMap, etag: &str) -> bool { headers .get(header::IF_NONE_MATCH) .and_then(|v| v.to_str().ok()) .map(|sent| sent == etag) .unwrap_or(false) } fn not_modified(etag: &str) -> Response { let mut response = StatusCode::NOT_MODIFIED.into_response(); if let Ok(value) = etag.parse() { response.headers_mut().insert(header::ETAG, value); } response } /// Scan `//` for cases whose earliest recording /// sits inside the `[since, now]` window. Returns entries sorted by /// `created_at` descending (newest first). Soft-deleted cases and /// non-UUID directories are filtered out. async fn enumerate_recent_oneliners( user_data_dir: &Path, since: OffsetDateTime, ) -> Vec { let mut entries: Vec = Vec::new(); let Ok(mut cases) = tokio::fs::read_dir(user_data_dir).await else { return entries; }; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); if !case_dir.is_dir() { continue; } let Some(case_id) = case_dir.file_name().and_then(|s| s.to_str()) else { continue; }; if uuid::Uuid::parse_str(case_id).is_err() { continue; } if crate::paths::is_deleted(&case_dir).await { continue; } let Some(created_mtime) = earliest_m4a_mtime(&case_dir).await else { continue; }; let created_at = OffsetDateTime::from(created_mtime); if created_at < since { continue; } let (oneliner_text, updated_at) = read_oneliner(&case_dir).await; let Ok(created_at_str) = created_at.format(&Rfc3339) else { warn!(case = %case_dir.display(), "format created_at failed"); continue; }; let updated_at_str = updated_at.and_then(|t| t.format(&Rfc3339).ok()); entries.push(OnelinerEntry { case_id: case_id.to_owned(), oneliner: oneliner_text, created_at: created_at_str, updated_at: updated_at_str, }); } entries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); entries } /// Return the earliest `.m4a` mtime in `case_dir`. `.m4a.failed` counts — /// a failed recording still marks the birth of the case. Returns None on /// read errors or empty directories. async fn earliest_m4a_mtime(case_dir: &Path) -> Option { let mut files = tokio::fs::read_dir(case_dir).await.ok()?; let mut earliest: Option = None; while let Ok(Some(entry)) = files.next_entry().await { let path: PathBuf = entry.path(); let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; if !(name.ends_with(".m4a") || name.ends_with(".m4a.failed")) { continue; } let Ok(meta) = entry.metadata().await else { continue; }; let Ok(mtime) = meta.modified() else { continue; }; earliest = match earliest { None => Some(mtime), Some(e) if mtime < e => Some(mtime), other => other, }; } earliest } /// Read `oneliner.txt` (trimmed, non-empty) plus its mtime. Missing file /// yields `(None, None)`. async fn read_oneliner(case_dir: &Path) -> (Option, Option) { let path = case_dir.join("oneliner.txt"); let text = tokio::fs::read_to_string(&path) .await .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); if text.is_none() { return (None, None); } let mtime = tokio::fs::metadata(&path) .await .ok() .and_then(|m| m.modified().ok()) .map(OffsetDateTime::from); (text, mtime) }