Files
doctate/server/src/routes/oneliners.rs
T
Brummel ac7157cdca feat: Add doctate-client-core crate
Introduces a new crate `doctate-client-core` to house shared client-side
logic. This includes:

- `case_store`: Manages local case marker files and merging with server
  snapshots.
- `snapshot_cache`: Placeholder for caching server responses.
- `oneliner_poller`: Placeholder for the background polling task.

The workspace configuration and `Cargo.lock` have been updated to
include the new crate.
2026-04-18 10:03:40 +02:00

195 lines
6.5 KiB
Rust

//! 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/"<unix>-<hours>"`
//! 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<u32>,
}
pub async fn handle_oneliners(
user: AuthenticatedUser,
State(watermark): State<OnelinerWatermark>,
Query(params): Query<OnelinersQuery>,
headers: HeaderMap,
) -> Result<Response, AppError> {
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<OffsetDateTime>, 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 `<user_data_dir>/<case_id>/` 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<OnelinerEntry> {
let mut entries: Vec<OnelinerEntry> = 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<SystemTime> {
let mut files = tokio::fs::read_dir(case_dir).await.ok()?;
let mut earliest: Option<SystemTime> = 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<String>, Option<OffsetDateTime>) {
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)
}