use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::SystemTime; use askama::Template; use axum::extract::{Path as AxumPath, State}; use axum::response::{Html, Redirect}; use time::format_description::well_known::Rfc3339; use time::OffsetDateTime; use tokio::io::AsyncWriteExt; use tracing::{info, warn}; use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::error::AppError; use crate::routes::user_web::locate_case; #[derive(Template)] #[template(path = "document.html")] struct DocumentTemplate { case_id: String, version: u32, content: String, } /// POST /web/cases/{case_id}/close /// /// Persist an `analysis_input_v1.json` for the case and enqueue an analyze /// job. Redirects back to the case detail page where the UI will show /// "wird analysiert …" until the worker writes `document_v1.md`. pub async fn handle_close_case( user: AuthenticatedWebUser, State(config): State>, State(analyze_tx): State, AxumPath(case_id): AxumPath, ) -> Result { uuid::Uuid::parse_str(&case_id) .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; // Server-side guard mirroring the UI gate: no close without a configured LLM. if !config.llm_configured() { return Err(AppError::ServiceUnavailable( "LLM-Analyse nicht konfiguriert".into(), )); } // Close only acts on cases in open/. A case in done/ is already finalized. let user_root = config.data_path.join(&user.slug); let case_dir = user_root.join("open").join(&case_id); if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) { warn!(slug = %user.slug, case_id = %case_id, "close: case not found in open/"); return Err(AppError::NotFound("Case not found".into())); } let input_path = case_dir.join("analysis_input_v1.json"); if tokio::fs::try_exists(&input_path).await.unwrap_or(false) { return Err(AppError::Conflict( "Analyse läuft bereits für diesen Fall".into(), )); } let input = build_analysis_input(&case_dir, 1).await?; write_input_create_new(&input_path, &input).await?; // Unbounded send: only fails if the worker has gone away (programmer error // or shutdown race). Log but don't fail the request — the file is already // on disk and recovery will pick it up on next startup. if analyze_tx .send(AnalyzeJob { case_dir: case_dir.clone(), version: 1, }) .is_err() { warn!(case_id = %case_id, "analyze send failed (worker gone)"); } info!( slug = %user.slug, case_id = %case_id, recordings = input.recordings.len(), "close requested; analysis enqueued" ); Ok(Redirect::to(&format!("/web/cases/{case_id}"))) } /// GET /web/cases/{case_id}/document /// /// Render the latest `document_v{N}.md` for the case. The handler scans the /// case directory and picks the highest N — no symlink, no separate pointer. pub async fn handle_document_view( user: AuthenticatedWebUser, State(config): State>, AxumPath(case_id): AxumPath, ) -> Result, AppError> { uuid::Uuid::parse_str(&case_id) .map_err(|_| AppError::BadRequest("Invalid case_id".into()))?; let user_root = config.data_path.join(&user.slug); let (case_dir, _status) = match locate_case(&user_root, &case_id).await { Some(v) => v, None => { warn!(slug = %user.slug, case_id = %case_id, "document view: case not found"); return Err(AppError::NotFound("Case not found".into())); } }; let (version, content) = find_latest_document(&case_dir) .await .ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?; DocumentTemplate { case_id, version, content, } .render() .map(Html) .map_err(|e| AppError::Internal(format!("Template render failed: {e}"))) } /// Build an `AnalysisInput` for the given case directory at the given version. /// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching /// `.transcript.txt` for each, skips blank transcripts, and computes /// `last_recording_mtime` as the max mtime over ALL `.m4a` (including blank /// ones — a late blank addendum still counts as activity). pub(crate) async fn build_analysis_input( case_dir: &Path, version: u32, ) -> Result { let m4as = collect_m4as(case_dir).await?; if m4as.is_empty() { return Err(AppError::BadRequest("Keine Aufnahmen im Fall".into())); } let mut recordings: Vec = Vec::new(); let mut last_mtime: SystemTime = SystemTime::UNIX_EPOCH; for (m4a_path, mtime) in &m4as { if *mtime > last_mtime { last_mtime = *mtime; } let transcript_path = m4a_path.with_extension("transcript.txt"); let text = tokio::fs::read_to_string(&transcript_path) .await .map_err(|_| { AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into()) })?; if text.trim().is_empty() { continue; } let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); recordings.push(RecordingInput { recorded_at: filename_stem_to_recorded_at(stem), text, }); } if recordings.is_empty() { return Err(AppError::BadRequest( "Keine nicht-leeren Transkripte im Fall".into(), )); } let last_recording_mtime = OffsetDateTime::from(last_mtime) .format(&Rfc3339) .map_err(|e| AppError::Internal(format!("format mtime: {e}")))?; Ok(AnalysisInput { version, last_recording_mtime, recordings, }) } /// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`, /// sorted by filename (chronological, since filenames are UTC timestamps). /// `.m4a.failed` entries are skipped. async fn collect_m4as(case_dir: &Path) -> Result, AppError> { let mut out = Vec::new(); let mut entries = tokio::fs::read_dir(case_dir).await?; while let Some(entry) = entries.next_entry().await? { let filename = match entry.file_name().into_string() { Ok(s) => s, Err(_) => continue, }; if !filename.ends_with(".m4a") { continue; } let path = entry.path(); let mtime = entry.metadata().await?.modified()?; out.push((path, mtime)); } out.sort_by(|a, b| a.0.cmp(&b.0)); Ok(out) } /// Serialize `input` and write it to `path` using `create_new` to atomically /// reserve the destination. Returns `Conflict` if another close already won /// the race. /// /// Trade-off: if the process crashes between `open` and `write_all`/`sync_all`, /// a zero-byte or partially-written JSON remains. The worker will fail to /// deserialize it and log an error — manual cleanup required. For MVP the /// simpler check-and-create is acceptable. pub(crate) async fn write_input_create_new( path: &Path, input: &AnalysisInput, ) -> Result<(), AppError> { let bytes = serde_json::to_vec_pretty(input) .map_err(|e| AppError::Internal(format!("serialize input: {e}")))?; let mut f = match tokio::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) .await { Ok(f) => f, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { return Err(AppError::Conflict( "Analyse läuft bereits für diesen Fall".into(), )); } Err(e) => return Err(AppError::Internal(e.to_string())), }; f.write_all(&bytes).await?; f.sync_all().await?; Ok(()) } /// Reverse the filesystem-safe timestamp mapping applied in `upload.rs`: /// `:` in the time portion was replaced with `-`. Split at `T`, undo hyphens /// only in the time part to avoid touching date hyphens. fn filename_stem_to_recorded_at(stem: &str) -> String { match stem.split_once('T') { Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")), None => stem.to_string(), } } /// Scan `case_dir` for `document_v{N}.md`, pick the highest N, return its /// content together with N. Returns `None` if no document exists. pub(crate) async fn find_latest_document(case_dir: &Path) -> Option<(u32, String)> { let mut highest: Option = None; let mut entries = tokio::fs::read_dir(case_dir).await.ok()?; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let Some(name_str) = name.to_str() else { continue }; let Some(version) = parse_document_version(name_str) else { continue; }; highest = match highest { Some(h) if h >= version => Some(h), _ => Some(version), }; } let v = highest?; let content = tokio::fs::read_to_string(case_dir.join(format!("document_v{v}.md"))) .await .ok()?; Some((v, content)) } fn parse_document_version(filename: &str) -> Option { let rest = filename.strip_prefix("document_v")?; let num = rest.strip_suffix(".md")?; if num.is_empty() || (num.starts_with('0') && num.len() > 1) { return None; } num.parse::().ok() } #[cfg(test)] mod tests { use super::*; #[test] fn filename_stem_roundtrip() { assert_eq!( filename_stem_to_recorded_at("2026-04-15T10-32-00Z"), "2026-04-15T10:32:00Z" ); // Date-only hyphens are preserved. assert_eq!( filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"), "2026-04-15T10:32:00.123Z" ); } #[test] fn parses_valid_document_names() { assert_eq!(parse_document_version("document_v1.md"), Some(1)); assert_eq!(parse_document_version("document_v42.md"), Some(42)); } #[test] fn rejects_invalid_document_names() { assert_eq!(parse_document_version("document_v01.md"), None); assert_eq!(parse_document_version("document_v.md"), None); assert_eq!(parse_document_version("document_vx.md"), None); assert_eq!(parse_document_version("analysis_input_v1.json"), None); assert_eq!(parse_document_version(""), None); } }