diff --git a/server/src/error.rs b/server/src/error.rs index d4f498d..4d99cb4 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -6,6 +6,11 @@ pub enum AppError { Unauthorized, BadRequest(String), NotFound(String), + /// 409 — request is valid but conflicts with current state (e.g. an + /// analysis is already running for this case). + Conflict(String), + /// 503 — the feature is unavailable right now (e.g. no LLM configured). + ServiceUnavailable(String), Internal(String), /// 302 redirect. Used by web auth to send unauthenticated users to login. Redirect(String), @@ -25,6 +30,8 @@ impl IntoResponse for AppError { Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()), Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), + Self::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), + Self::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()), Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()), Self::Redirect(_) => unreachable!(), }; diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs new file mode 100644 index 0000000..253d05c --- /dev/null +++ b/server/src/routes/case_actions.rs @@ -0,0 +1,294 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::SystemTime; + +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; + +/// 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(), + )); + } + + // Collect all active `.m4a` recordings and the transcripts next to them. + // `.m4a.failed` entries are deliberately skipped: they block nothing and + // carry no transcript. The doctor handles them manually. + 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() { + // Skip blank transcripts from the LLM input — they add noise + // without signal. The `.m4a` still contributes to `last_mtime`. + 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}")))?; + + let input = AnalysisInput { + version: 1, + last_recording_mtime, + recordings, + }; + + 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()))?; + + // Minimal inline HTML for step 4; replaced with a proper template in step 7. + let escaped = content + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + Ok(Html(format!( + "Dokument v{version}
{escaped}

Zurück

" + ))) +} + +/// 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. +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. +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); + } +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index e3e30f5..1b39411 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,9 +1,10 @@ +mod case_actions; mod debug; mod health; mod login; mod upload; +pub(crate) mod user_web; pub(crate) mod web; -mod user_web; use axum::routing::{get, post}; use axum::Router; @@ -19,6 +20,14 @@ pub fn api_router() -> Router { .route("/web/logout", post(login::handle_logout)) .route("/web/cases", get(user_web::handle_my_cases)) .route("/web/cases/{case_id}", get(user_web::handle_case_detail)) + .route( + "/web/cases/{case_id}/close", + post(case_actions::handle_close_case), + ) + .route( + "/web/cases/{case_id}/document", + get(case_actions::handle_document_view), + ) .route("/web/", get(web::handle_case_list)) .route( "/web/audio/{user}/{case_id}/{filename}", diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 1c68aaa..8d17033 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -97,7 +97,7 @@ pub async fn handle_case_detail( /// Locate a case directory under `/{open|done}/`. /// Returns the path and the status bucket it was found in. -async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> { +pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> { for status in ["open", "done"] { let p = user_root.join(status).join(case_id); if tokio::fs::try_exists(&p).await.unwrap_or(false) {