From 0b63d8ff568d054e3d35c393829a7e8ca77f911d Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 15 Apr 2026 23:06:31 +0200 Subject: [PATCH] Feat: Add bulk actions for case analysis and deletion Introduces a new `/web/cases/bulk` endpoint to handle multiple case actions simultaneously. This change adds the following: - A new `bulk` module for handling bulk operations. - The `handle_bulk_action` function in `bulk.rs` to dispatch to specific actions. - `bulk_analyze` and `bulk_delete` functions to process the actions. - Updates `Cargo.toml` and `Cargo.lock` to include necessary dependencies: `form_urlencoded`, `serde_core`, `serde_html_form`, and `serde_path_to_error`. - Modified `axum-extra` features to include `form`. - Adds checks for deleted cases in `collect_pending` for both analysis and transcription recovery. - Renames and modifies `handle_close_case` to `handle_analyze_case` in `case_actions.rs` to better reflect its functionality. - Adds a new `handle_delete_case` and `handle_undo_delete` to `case_actions.rs`. - Updates `my_cases.html` and `case_detail.html` to support bulk actions, including checkboxes, a bulk action bar, and an "Undo last delete" feature. - Modifies `handle_audio` and `scan_cases` in `web.rs` to respect delete markers. - Updates `analyze_test.rs` with new request builders for analyze and delete actions. --- server/Cargo.lock | 17 + server/Cargo.toml | 2 +- server/src/analyze/recovery.rs | 3 + server/src/routes/bulk.rs | 143 +++++++ server/src/routes/case_actions.rs | 249 ++++++++---- server/src/routes/mod.rs | 14 +- server/src/routes/user_web.rs | 78 ++-- server/src/routes/web.rs | 9 +- server/src/transcribe/recovery.rs | 3 + server/templates/case_detail.html | 9 +- server/templates/my_cases.html | 58 ++- .../{close_case_test.rs => analyze_test.rs} | 365 ++++++++++-------- 12 files changed, 662 insertions(+), 288 deletions(-) create mode 100644 server/src/routes/bulk.rs rename server/tests/{close_case_test.rs => analyze_test.rs} (70%) diff --git a/server/Cargo.lock b/server/Cargo.lock index 467deeb..5c0acd4 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -146,6 +146,7 @@ dependencies = [ "axum-core", "bytes", "cookie", + "form_urlencoded", "futures-core", "futures-util", "http", @@ -153,6 +154,9 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", + "serde_core", + "serde_html_form", + "serde_path_to_error", "tower-layer", "tower-service", "tracing", @@ -1493,6 +1497,19 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_html_form" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_json" version = "1.0.149" diff --git a/server/Cargo.toml b/server/Cargo.toml index 6bdc59f..8b3def4 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,7 +21,7 @@ toml = "0.8" tower-http = { version = "0.6", features = ["limit", "trace"] } rand = "0.8" bcrypt = "0.15" -axum-extra = { version = "0.12", features = ["cookie"] } +axum-extra = { version = "0.12", features = ["cookie", "form"] } time = "0.3.47" toml_edit = "0.25.11" rpassword = "7.4.0" diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 8db4ac7..d22c5d7 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -43,6 +43,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> { if !case_dir.is_dir() { continue; } + if crate::paths::is_deleted(&case_dir).await { + continue; + } let mut files = match tokio::fs::read_dir(&case_dir).await { Ok(r) => r, diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs new file mode 100644 index 0000000..6dbf072 --- /dev/null +++ b/server/src/routes/bulk.rs @@ -0,0 +1,143 @@ +use std::sync::Arc; + +use axum::extract::State; +use axum::response::Redirect; +use axum_extra::extract::Form; +use serde::Deserialize; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; +use tracing::{info, warn}; + +use crate::analyze::{AnalyzeJob, AnalyzeSender}; +use crate::auth::AuthenticatedWebUser; +use crate::config::Config; +use crate::error::AppError; +use crate::paths::{write_delete_marker, DeleteMarker}; +use crate::routes::case_actions::{build_analysis_input, find_latest_document, write_input_create_new}; +use crate::routes::user_web::locate_case; + +#[derive(Debug, Deserialize)] +pub struct BulkForm { + action: String, + /// Repeated `case_id=` form fields. Empty for an empty selection + /// (handled as a no-op). + #[serde(default, rename = "case_id")] + case_ids: Vec, +} + +/// POST /web/cases/bulk — apply `action` (analyze | delete) to every +/// selected case. Errors per case are logged but do not abort the batch: +/// a single bad/missing case must not block the rest. +pub async fn handle_bulk_action( + user: AuthenticatedWebUser, + State(config): State>, + State(analyze_tx): State, + Form(form): Form, +) -> Result { + let user_root = config.data_path.join(&user.slug); + + match form.action.as_str() { + "analyze" => bulk_analyze(&config, &analyze_tx, &user.slug, &user_root, &form.case_ids).await, + "delete" => bulk_delete(&user.slug, &user_root, &form.case_ids).await, + other => { + warn!(slug = %user.slug, action = %other, "bulk: unknown action"); + return Err(AppError::BadRequest("Unbekannte Aktion".into())); + } + } + + Ok(Redirect::to("/web/cases")) +} + +async fn bulk_analyze( + config: &Config, + analyze_tx: &AnalyzeSender, + slug: &str, + user_root: &std::path::Path, + case_ids: &[String], +) { + if !config.llm_configured() { + warn!(slug = %slug, "bulk-analyze: LLM not configured, skipping all"); + return; + } + let mut ok = 0usize; + let mut skipped = 0usize; + for case_id in case_ids { + if uuid::Uuid::parse_str(case_id).is_err() { + warn!(slug = %slug, case_id = %case_id, "bulk-analyze: invalid case_id"); + skipped += 1; + continue; + } + let Some(case_dir) = locate_case(user_root, case_id).await else { + warn!(slug = %slug, case_id = %case_id, "bulk-analyze: case not found"); + skipped += 1; + continue; + }; + let version = find_latest_document(&case_dir) + .await + .map(|(v, _)| v + 1) + .unwrap_or(1); + let input = match build_analysis_input(&case_dir, version).await { + Ok(i) => i, + Err(_) => { + warn!(slug = %slug, case_id = %case_id, "bulk-analyze: build_analysis_input failed"); + skipped += 1; + continue; + } + }; + let input_path = case_dir.join(format!("analysis_input_v{version}.json")); + if write_input_create_new(&input_path, &input).await.is_err() { + warn!(slug = %slug, case_id = %case_id, "bulk-analyze: write input failed"); + skipped += 1; + continue; + } + if analyze_tx + .send(AnalyzeJob { + case_dir: case_dir.clone(), + version, + }) + .is_err() + { + warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)"); + } + ok += 1; + } + info!(slug = %slug, ok, skipped, "bulk-analyze completed"); +} + +async fn bulk_delete(slug: &str, user_root: &std::path::Path, case_ids: &[String]) { + // One batch UUID + one timestamp shared across the entire bulk action, + // so undo can restore exactly this group. + let batch = uuid::Uuid::new_v4(); + let deleted_at = match OffsetDateTime::now_utc().format(&Rfc3339) { + Ok(s) => s, + Err(e) => { + warn!(slug = %slug, error = %e, "bulk-delete: failed to format timestamp"); + return; + } + }; + let mut ok = 0usize; + let mut skipped = 0usize; + for case_id in case_ids { + if uuid::Uuid::parse_str(case_id).is_err() { + warn!(slug = %slug, case_id = %case_id, "bulk-delete: invalid case_id"); + skipped += 1; + continue; + } + let Some(case_dir) = locate_case(user_root, case_id).await else { + warn!(slug = %slug, case_id = %case_id, "bulk-delete: case not found"); + skipped += 1; + continue; + }; + let marker = DeleteMarker { + batch, + deleted_at: deleted_at.clone(), + }; + if let Err(e) = write_delete_marker(&case_dir, &marker).await { + warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-delete: write marker failed"); + skipped += 1; + continue; + } + ok += 1; + } + info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed"); +} diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index 00730fe..67ed4f6 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -14,6 +14,7 @@ use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::error::AppError; +use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER}; use crate::routes::user_web::locate_case; #[derive(Template)] @@ -24,12 +25,14 @@ struct DocumentTemplate { content: String, } -/// POST /web/cases/{case_id}/close +/// POST /web/cases/{case_id}/analyze /// -/// 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( +/// Trigger an LLM analysis for the case. If no document exists yet, writes +/// `analysis_input_v1.json`; otherwise writes `analysis_input_v{N+1}.json` +/// where N is the current latest document version. The worker picks up the +/// new input and produces `document_v{version}.md`. Available to all logged-in +/// users (no admin check). Race protection via `create_new` on the input file. +pub async fn handle_analyze_case( user: AuthenticatedWebUser, State(config): State>, State(analyze_tx): State, @@ -38,7 +41,6 @@ pub async fn handle_close_case( 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(), @@ -49,20 +51,18 @@ pub async fn handle_close_case( let case_dir = match locate_case(&user_root, &case_id).await { Some(p) => p, None => { - warn!(slug = %user.slug, case_id = %case_id, "close: case not found"); + warn!(slug = %user.slug, case_id = %case_id, "analyze: case not found"); 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?; + let version = find_latest_document(&case_dir) + .await + .map(|(v, _)| v + 1) + .unwrap_or(1); + let input = build_analysis_input(&case_dir, version).await?; + let input_path = case_dir.join(format!("analysis_input_v{version}.json")); write_input_create_new(&input_path, &input).await?; // Unbounded send: only fails if the worker has gone away (programmer error @@ -71,7 +71,7 @@ pub async fn handle_close_case( if analyze_tx .send(AnalyzeJob { case_dir: case_dir.clone(), - version: 1, + version, }) .is_err() { @@ -81,76 +81,9 @@ pub async fn handle_close_case( info!( slug = %user.slug, case_id = %case_id, + version, recordings = input.recordings.len(), - "close requested; analysis enqueued" - ); - - Ok(Redirect::to(&format!("/web/cases/{case_id}"))) -} - -/// POST /web/cases/{case_id}/reanalyze -/// -/// Admin-only. Build a fresh `analysis_input_v{N+1}.json` from the current -/// transcripts and enqueue a new analyze job. `find_latest_document` picks the -/// currently-highest N; the worker will write `document_v{N+1}.md`, which then -/// wins the "highest version" race and becomes the displayed document. -pub async fn handle_reanalyze_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()))?; - - if !user.is_admin() { - return Err(AppError::Forbidden("Admin erforderlich".into())); - } - - if !config.llm_configured() { - return Err(AppError::ServiceUnavailable( - "LLM-Analyse nicht konfiguriert".into(), - )); - } - - let user_root = config.data_path.join(&user.slug); - let case_dir = match locate_case(&user_root, &case_id).await { - Some(p) => p, - None => { - warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found"); - return Err(AppError::NotFound("Case not found".into())); - } - }; - - let current_version = find_latest_document(&case_dir) - .await - .map(|(v, _)| v) - .ok_or_else(|| { - AppError::BadRequest("Kein bestehendes Dokument — nutze 'Fall abschließen'".into()) - })?; - let next_version = current_version + 1; - - let input = build_analysis_input(&case_dir, next_version).await?; - - let input_path = case_dir.join(format!("analysis_input_v{next_version}.json")); - write_input_create_new(&input_path, &input).await?; - - if analyze_tx - .send(AnalyzeJob { - case_dir: case_dir.clone(), - version: next_version, - }) - .is_err() - { - warn!(case_id = %case_id, "analyze send failed (worker gone)"); - } - - info!( - slug = %user.slug, - case_id = %case_id, - version = next_version, - recordings = input.recordings.len(), - "reanalyze requested; analysis enqueued" + "analyze requested; analysis enqueued" ); Ok(Redirect::to(&format!("/web/cases/{case_id}"))) @@ -345,6 +278,152 @@ fn parse_document_version(filename: &str) -> Option { num.parse::().ok() } +/// POST /web/cases/{case_id}/delete +/// +/// Soft-delete: writes a `.deleted` JSON marker into the case directory. +/// The case immediately disappears from listings and detail views (404). +/// Reversible via `handle_undo_delete` while the marker still has the +/// most recent `deleted_at` timestamp among the user's deleted cases. +pub async fn handle_delete_case( + user: AuthenticatedWebUser, + State(config): State>, + AxumPath(case_id): AxumPath, +) -> Result { + 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 = match locate_case(&user_root, &case_id).await { + Some(p) => p, + None => { + warn!(slug = %user.slug, case_id = %case_id, "delete: case not found"); + return Err(AppError::NotFound("Case not found".into())); + } + }; + + let marker = DeleteMarker { + batch: uuid::Uuid::new_v4(), + deleted_at: now_rfc3339()?, + }; + write_delete_marker(&case_dir, &marker).await?; + + info!( + slug = %user.slug, + case_id = %case_id, + batch = %marker.batch, + "case soft-deleted" + ); + + Ok(Redirect::to("/web/cases")) +} + +/// POST /web/cases/undo-delete +/// +/// Restore every case in the most recent delete batch by removing its +/// `.deleted` marker. "Most recent" = highest `deleted_at` across all +/// markers under the user's data directory; ties broken by `batch`-UUID +/// lexicographic order. No-op if no markers exist. +pub async fn handle_undo_delete( + user: AuthenticatedWebUser, + State(config): State>, +) -> Result { + let user_root = config.data_path.join(&user.slug); + + let latest = latest_delete_batch(&user_root).await; + let Some((batch, _)) = latest else { + info!(slug = %user.slug, "undo-delete: nothing to undo"); + return Ok(Redirect::to("/web/cases")); + }; + + let restored = restore_batch(&user_root, batch).await; + info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed"); + + Ok(Redirect::to("/web/cases")) +} + +/// Summarize the most recent delete batch under `user_root`: returns +/// `(batch_uuid, count)` where count is the number of cases sharing that +/// batch. `None` if no `.deleted` markers exist. Single directory scan. +pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uuid, usize)> { + let (batch, _) = latest_delete_batch(user_root).await?; + let mut count = 0usize; + let mut entries = tokio::fs::read_dir(user_root).await.ok()?; + while let Ok(Some(entry)) = entries.next_entry().await { + let case_path = entry.path(); + if !case_path.is_dir() { + continue; + } + if let Some(marker) = read_delete_marker(&case_path).await + && marker.batch == batch + { + count += 1; + } + } + Some((batch, count)) +} + +/// Scan the user's data dir, return the `(batch, deleted_at)` of the +/// most-recently-deleted case across all markers. `None` if no markers. +async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> { + let mut entries = tokio::fs::read_dir(user_root).await.ok()?; + let mut best: Option<(uuid::Uuid, String)> = None; + while let Ok(Some(entry)) = entries.next_entry().await { + if !entry.path().is_dir() { + continue; + } + let Some(marker) = read_delete_marker(&entry.path()).await else { + continue; + }; + let candidate = (marker.batch, marker.deleted_at); + best = match best { + None => Some(candidate), + // Order: deleted_at desc, then batch desc as tie-breaker. + Some(ref cur) + if candidate.1 > cur.1 + || (candidate.1 == cur.1 && candidate.0 > cur.0) => + { + Some(candidate) + } + other => other, + }; + } + best +} + +/// Remove `.deleted` markers from every case whose marker carries `batch`. +/// Returns the number of restored cases. +async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize { + let mut count = 0; + let mut entries = match tokio::fs::read_dir(user_root).await { + Ok(r) => r, + Err(_) => return 0, + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let case_path = entry.path(); + if !case_path.is_dir() { + continue; + } + let Some(marker) = read_delete_marker(&case_path).await else { + continue; + }; + if marker.batch != batch { + continue; + } + if let Err(e) = tokio::fs::remove_file(case_path.join(DELETE_MARKER)).await { + warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker"); + continue; + } + count += 1; + } + count +} + +fn now_rfc3339() -> Result { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|e| AppError::Internal(format!("format timestamp: {e}"))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 679585d..0f6f528 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -1,3 +1,4 @@ +mod bulk; mod case_actions; mod debug; mod health; @@ -21,13 +22,18 @@ pub fn api_router() -> Router { .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), + "/web/cases/{case_id}/analyze", + post(case_actions::handle_analyze_case), ) .route( - "/web/cases/{case_id}/reanalyze", - post(case_actions::handle_reanalyze_case), + "/web/cases/{case_id}/delete", + post(case_actions::handle_delete_case), ) + .route( + "/web/cases/undo-delete", + post(case_actions::handle_undo_delete), + ) + .route("/web/cases/bulk", post(bulk::handle_bulk_action)) .route( "/web/cases/{case_id}/document", get(case_actions::handle_document_view), diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 5f505c2..43d05fc 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -16,9 +16,13 @@ struct UserCaseView { case_id_short: String, most_recent: String, oneliner: Option, - recordings: Vec, + recordings_count: usize, analyzing: bool, has_document: bool, + /// True iff the inline "Analysieren"-button should be enabled. + /// Requires: not currently analyzing, all non-failed recordings + /// transcribed, and an LLM is configured. + can_analyze: bool, } #[derive(Template)] @@ -26,6 +30,9 @@ struct UserCaseView { struct MyCasesTemplate { slug: String, cases: Vec, + /// Number of cases that "Undo last delete" would restore. 0 hides the + /// undo button entirely. + undo_count: usize, } #[derive(Template)] @@ -36,30 +43,28 @@ struct CaseDetailTemplate { case_id_short: String, oneliner: Option, recordings: Vec, - can_close: bool, + can_analyze: bool, llm_missing: bool, analyzing: bool, has_document: bool, - can_reanalyze: bool, } -/// Flags derived from filesystem state + config. Priority order -/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`, -/// then `llm_missing`. If none is true, the case is still receiving -/// recordings or has zero transcripts; no action surfaced. +/// Flags derived from filesystem state + config. +/// `can_analyze` covers both first analysis and re-analysis — same precondition +/// (all non-failed recordings transcribed, LLM configured, no analysis in +/// flight). `llm_missing` is shown to the user as an explanation when an +/// analysis would otherwise be possible but no LLM is configured. struct CaseFlags { has_document: bool, analyzing: bool, - can_close: bool, + can_analyze: bool, llm_missing: bool, - can_reanalyze: bool, } async fn compute_flags( case_dir: &Path, recordings: &[RecordingView], llm_configured: bool, - is_admin: bool, ) -> CaseFlags { let has_document = any_document_exists(case_dir).await; let analyzing = !has_document @@ -71,14 +76,13 @@ async fn compute_flags( let all_transcribed = !non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some()); - let transcribed_stage = !has_document && !analyzing && all_transcribed; + let analyzable = !analyzing && all_transcribed; CaseFlags { has_document, analyzing, - can_close: transcribed_stage && llm_configured, - llm_missing: transcribed_stage && !llm_configured, - can_reanalyze: has_document && is_admin && llm_configured, + can_analyze: analyzable && llm_configured, + llm_missing: analyzable && !llm_configured, } } @@ -103,10 +107,16 @@ pub async fn handle_my_cases( user: AuthenticatedWebUser, State(config): State>, ) -> Result, AppError> { - let cases = scan_user_cases(&config.data_path, &user.slug).await; + let cases = scan_user_cases(&config, &user.slug).await; + let user_root = config.data_path.join(&user.slug); + let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root) + .await + .map(|(_, n)| n) + .unwrap_or(0); MyCasesTemplate { slug: user.slug, cases, + undo_count, } .render() .map(Html) @@ -139,13 +149,7 @@ pub async fn handle_case_detail( .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); - let flags = compute_flags( - &case_dir, - &recordings, - config.llm_configured(), - user.is_admin(), - ) - .await; + let flags = compute_flags(&case_dir, &recordings, config.llm_configured()).await; let case_id_short = case_id.chars().take(8).collect(); CaseDetailTemplate { @@ -154,11 +158,10 @@ pub async fn handle_case_detail( case_id_short, oneliner, recordings, - can_close: flags.can_close, + can_analyze: flags.can_analyze, llm_missing: flags.llm_missing, analyzing: flags.analyzing, has_document: flags.has_document, - can_reanalyze: flags.can_reanalyze, } .render() .map(Html) @@ -181,9 +184,11 @@ pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option Vec { - let user_root = data_path.join(slug); +/// timestamps are ISO-8601), newest first. Soft-deleted cases (with +/// `.deleted` marker) are excluded. +async fn scan_user_cases(config: &Config, slug: &str) -> Vec { + let user_root = config.data_path.join(slug); + let llm_configured = config.llm_configured(); let mut cases = Vec::new(); let mut entries = match tokio::fs::read_dir(&user_root).await { @@ -200,11 +205,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec { warn!(case_id = %case_id, "Skipping non-UUID directory"); continue; } - if !case_entry.path().is_dir() { + let case_path = case_entry.path(); + if !case_path.is_dir() { + continue; + } + if crate::paths::is_deleted(&case_path).await { continue; } - let case_path = case_entry.path(); let recordings = scan_recordings(&case_path).await; if recordings.is_empty() { continue; @@ -214,6 +222,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec { .last() .map(|r| r.filename.clone()) .unwrap_or_default(); + let recordings_count = recordings.len(); + let non_failed_count = recordings.iter().filter(|r| !r.failed).count(); + let all_transcribed = non_failed_count > 0 + && recordings + .iter() + .filter(|r| !r.failed) + .all(|r| r.transcript.is_some()); + let case_id_short = case_id.chars().take(8).collect(); let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt")) .await @@ -226,15 +242,17 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec { && tokio::fs::try_exists(case_path.join("analysis_input_v1.json")) .await .unwrap_or(false); + let can_analyze = !analyzing && all_transcribed && llm_configured; cases.push(UserCaseView { case_id, case_id_short, most_recent, oneliner, - recordings, + recordings_count, analyzing, has_document, + can_analyze, }); } diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index 0a5b7f2..5e4bfb5 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -56,7 +56,11 @@ pub async fn handle_audio( .map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?; validate_filename(&filename)?; - let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&filename); + let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id); + if crate::paths::is_deleted(&case_path).await { + return Err(AppError::NotFound("Audio file not found".into())); + } + let file_path = case_path.join(&filename); if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) { return Err(AppError::NotFound("Audio file not found".into())); } @@ -129,6 +133,9 @@ async fn scan_cases(data_path: &Path) -> Vec { } let case_path = case_entry.path(); + if crate::paths::is_deleted(&case_path).await { + continue; + } let recordings = scan_recordings(&case_path).await; if recordings.is_empty() { continue; diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index e941c49..f837a2f 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -55,6 +55,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> { if !case_dir.is_dir() { continue; } + if crate::paths::is_deleted(&case_dir).await { + continue; + } let mut files = match tokio::fs::read_dir(&case_dir).await { Ok(r) => r, diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 2f61db0..2fb6760 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -45,16 +45,15 @@ header form { margin: 0; }
{% if has_document %} Ergebnis öffnen -{% if can_reanalyze %} -
{% endif %} -{% else if analyzing %} +{% if analyzing %} wird analysiert … -{% else if can_close %} -
+{% else if can_analyze %} +
{% else if llm_missing %} LLM-Analyse nicht konfiguriert {% endif %} +

Aufnahmen ({{ recordings.len() }})

diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index dc07de7..ccea9ae 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -7,19 +7,32 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; } header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } -.case { border: 1px solid #ccc; margin: 0.8em 0; padding: 0.8em 1em; border-radius: 4px; display: block; text-decoration: none; color: inherit; } -.case:hover { background: #f6f6f6; } -.case h2 { margin: 0 0 0.3em 0; font-size: 1em; } -.case small { color: #666; font-family: monospace; } -.case.open { border-left: 4px solid #4a90e2; } -.case.done { border-left: 4px solid #7ed321; } -.oneliner { font-size: 1em; color: #333; margin: 0.2em 0; } section { margin: 1.5em 0; } section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; } .empty { color: #888; font-style: italic; } -.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; } + +.case-list { list-style: none; padding: 0; margin: 0; } +.case-row { display: grid; grid-template-columns: auto 1fr auto; gap: 0.6em 1em; align-items: center; padding: 0.7em 1em; border: 1px solid #ccc; border-radius: 4px; margin: 0.5em 0; } +.case-row.done { border-left: 4px solid #7ed321; } +.case-row.open { border-left: 4px solid #4a90e2; } +.case-row .check { align-self: start; padding-top: 0.25em; } +.case-row .body { min-width: 0; } +.case-row .body a { color: inherit; text-decoration: none; } +.case-row .body a:hover h3 { text-decoration: underline; } +.case-row .body h3 { margin: 0 0 0.2em 0; font-size: 1em; font-weight: 600; } +.case-row .body small { color: #666; font-family: monospace; font-size: 0.85em; } +.case-row .oneliner { color: #333; margin: 0.2em 0 0; } +.case-row .actions { display: flex; gap: 0.4em; } +.case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; } + +.label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; } .label.analyzing { background: #eee; color: #555; } .label.done-doc { background: #7ed321; color: white; } + +.bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; } +.bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; } +.bulk-bar .undo { margin-left: auto; } +.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; } @@ -28,14 +41,26 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
+{% if undo_count > 0 %} +
+{{ undo_count }} zuletzt gelöschte(r) Fall(/Fälle). + +
+{% endif %} +

Fälle ({{ cases.len() }})

{% if cases.is_empty() %}

Keine Fälle.

{% else %} +
+ +
+Auswahl: + + +
+
{% endif %}
diff --git a/server/tests/close_case_test.rs b/server/tests/analyze_test.rs similarity index 70% rename from server/tests/close_case_test.rs rename to server/tests/analyze_test.rs index 52e48c2..71f8331 100644 --- a/server/tests/close_case_test.rs +++ b/server/tests/analyze_test.rs @@ -54,16 +54,6 @@ fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) }) } -fn make_admin(slug: &str) -> User { - User { - slug: slug.into(), - api_key: format!("key-{slug}"), - web_password: bcrypt::hash("s", 4).unwrap(), - role: "admin".into(), - whisper: Default::default(), - } -} - fn config_without_llm(data_path: PathBuf) -> Arc { let users = vec![make_user("dr_a")]; let api_keys: HashMap = users @@ -117,10 +107,19 @@ async fn login(app: axum::Router, slug: &str) -> String { panic!("no session cookie after login"); } -fn close_request(case_id: &str, cookie: &str) -> Request { +fn analyze_request(case_id: &str, cookie: &str) -> Request { Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/close")) + .uri(format!("/web/cases/{case_id}/analyze")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +fn delete_request(case_id: &str, cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri(format!("/web/cases/{case_id}/delete")) .header(header::COOKIE, cookie) .body(Body::empty()) .unwrap() @@ -131,7 +130,7 @@ fn close_request(case_id: &str, cookie: &str) -> Request { // --------------------------------------------------------------------- #[tokio::test] -async fn close_without_cookie_redirects_to_login() { +async fn analyze_without_cookie_redirects_to_login() { let config = config_with_llm(unique_tmp("a"), "http://unused".into()); let app = doctate_server::create_router(config); let case_id = "11111111-1111-1111-1111-111111111111"; @@ -139,7 +138,7 @@ async fn close_without_cookie_redirects_to_login() { .oneshot( Request::builder() .method("POST") - .uri(format!("/web/cases/{case_id}/close")) + .uri(format!("/web/cases/{case_id}/analyze")) .body(Body::empty()) .unwrap(), ) @@ -149,33 +148,33 @@ async fn close_without_cookie_redirects_to_login() { } #[tokio::test] -async fn close_foreign_case_returns_404() { +async fn analyze_foreign_case_returns_404() { let config = config_with_llm(unique_tmp("b"), "http://unused".into()); // Seed a case owned by someone else. let foreign_case = "22222222-2222-2222-2222-222222222222"; - let foreign_dir = config.data_path.join("dr_other/open").join(foreign_case); + let foreign_dir = config.data_path.join("dr_other").join(foreign_case); std::fs::create_dir_all(&foreign_dir).unwrap(); seed_recording(&foreign_dir, "10-00-00", Some("text")); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(foreign_case, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(foreign_case, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] -async fn close_invalid_uuid_returns_400() { +async fn analyze_invalid_uuid_returns_400() { let config = config_with_llm(unique_tmp("c"), "http://unused".into()); let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request("not-a-uuid", &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request("not-a-uuid", &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] -async fn close_case_without_recordings_returns_400() { +async fn analyze_case_without_recordings_returns_400() { let config = config_with_llm(unique_tmp("d"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; seed_case(&config.data_path, "dr_a", case_id); @@ -184,12 +183,12 @@ async fn close_case_without_recordings_returns_400() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] -async fn close_case_with_missing_transcript_returns_400() { +async fn analyze_case_with_missing_transcript_returns_400() { let config = config_with_llm(unique_tmp("e"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); @@ -199,12 +198,12 @@ async fn close_case_with_missing_transcript_returns_400() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] -async fn close_case_without_llm_returns_503() { +async fn analyze_case_without_llm_returns_503() { let config = config_without_llm(unique_tmp("f")); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); @@ -213,7 +212,7 @@ async fn close_case_without_llm_returns_503() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); } @@ -222,7 +221,7 @@ async fn close_case_without_llm_returns_503() { // --------------------------------------------------------------------- #[tokio::test] -async fn close_case_happy_path_writes_input_and_redirects() { +async fn analyze_case_happy_path_writes_input_and_redirects() { let config = config_with_llm(unique_tmp("g"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); @@ -232,7 +231,7 @@ async fn close_case_happy_path_writes_input_and_redirects() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!( resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), @@ -254,7 +253,7 @@ async fn close_case_happy_path_writes_input_and_redirects() { } #[tokio::test] -async fn close_case_second_time_returns_409() { +async fn analyze_case_second_time_returns_409() { let config = config_with_llm(unique_tmp("h"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); @@ -263,15 +262,15 @@ async fn close_case_second_time_returns_409() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let first = app.clone().oneshot(close_request(case_id, &cookie)).await.unwrap(); + let first = app.clone().oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(first.status(), StatusCode::SEE_OTHER); - let second = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let second = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(second.status(), StatusCode::CONFLICT); } #[tokio::test] -async fn close_case_skips_blank_transcript_from_recordings() { +async fn analyze_case_skips_blank_transcript_from_recordings() { let config = config_with_llm(unique_tmp("i"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); @@ -282,7 +281,7 @@ async fn close_case_skips_blank_transcript_from_recordings() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(close_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let raw = std::fs::read_to_string(case_dir.join("analysis_input_v1.json")).unwrap(); @@ -424,102 +423,12 @@ async fn document_view_picks_highest_version() { } // --------------------------------------------------------------------- -// Reanalyze (admin-only) +// Re-analysis path: same handler, version derived from existing documents // --------------------------------------------------------------------- -fn reanalyze_request(case_id: &str, cookie: &str) -> Request { - Request::builder() - .method("POST") - .uri(format!("/web/cases/{case_id}/reanalyze")) - .header(header::COOKIE, cookie) - .body(Body::empty()) - .unwrap() -} - #[tokio::test] -async fn reanalyze_by_non_admin_returns_403() { - let config = config_with_llm(unique_tmp("rn-1"), "http://unused".into()); - let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); - seed_recording(&case_dir, "10-00-00", Some("ok")); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::FORBIDDEN); -} - -#[tokio::test] -async fn reanalyze_without_document_returns_400() { - let config = config_with_llm_users( - unique_tmp("rn-2"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); - let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - seed_recording(&case_dir, "10-00-00", Some("ok")); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn reanalyze_foreign_case_returns_404() { - let config = config_with_llm_users( - unique_tmp("rn-3"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); - let foreign_case = "22222222-2222-2222-2222-222222222222"; - let foreign_dir = config.data_path.join("dr_other/open").join(foreign_case); - std::fs::create_dir_all(&foreign_dir).unwrap(); - std::fs::write(foreign_dir.join("document_v1.md"), "v1").unwrap(); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app.oneshot(reanalyze_request(foreign_case, &cookie)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn reanalyze_without_llm_returns_503() { - let users = vec![make_admin("dr_a")]; - let api_keys: HashMap = users - .iter() - .map(|u| (u.api_key.clone(), u.slug.clone())) - .collect(); - let config = Arc::new(Config { - data_path: unique_tmp("rn-4"), - users, - api_keys, - ..Config::test_default() - }); - let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); - seed_recording(&case_dir, "10-00-00", Some("ok")); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); - assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); -} - -#[tokio::test] -async fn reanalyze_happy_path_writes_v2_input() { - let config = config_with_llm_users( - unique_tmp("rn-5"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); +async fn analyze_writes_v2_when_document_exists() { + let config = config_with_llm(unique_tmp("rn-5"), "http://unused".into()); let case_id = "11111111-1111-1111-1111-111111111111"; let case_dir = seed_case(&config.data_path, "dr_a", case_id); std::fs::write(case_dir.join("document_v1.md"), "altes Dokument").unwrap(); @@ -529,7 +438,7 @@ async fn reanalyze_happy_path_writes_v2_input() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - let resp = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); + let resp = app.oneshot(analyze_request(case_id, &cookie)).await.unwrap(); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let input_path = case_dir.join("analysis_input_v2.json"); @@ -537,34 +446,11 @@ async fn reanalyze_happy_path_writes_v2_input() { let parsed: Value = serde_json::from_str(&std::fs::read_to_string(&input_path).unwrap()).unwrap(); assert_eq!(parsed["version"], 2); assert_eq!(parsed["recordings"].as_array().unwrap().len(), 2); - // v1 document is still there until worker writes v2. assert!(case_dir.join("document_v1.md").exists()); } #[tokio::test] -async fn reanalyze_second_time_returns_409() { - let config = config_with_llm_users( - unique_tmp("rn-6"), - "http://unused".into(), - vec![make_admin("dr_a")], - ); - let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document_v1.md"), "v1").unwrap(); - seed_recording(&case_dir, "10-00-00", Some("ok")); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let first = app.clone().oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); - assert_eq!(first.status(), StatusCode::SEE_OTHER); - - let second = app.oneshot(reanalyze_request(case_id, &cookie)).await.unwrap(); - assert_eq!(second.status(), StatusCode::CONFLICT); -} - -#[tokio::test] -async fn reanalyze_worker_writes_document_v2_via_wiremock() { +async fn analyze_v2_worker_writes_document_v2_via_wiremock() { let tmp = unique_tmp("rn-w"); let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); std::fs::create_dir_all(&case_dir).unwrap(); @@ -592,7 +478,7 @@ async fn reanalyze_worker_writes_document_v2_via_wiremock() { .mount(&mock) .await; - let config = config_with_llm_users(tmp.clone(), mock.uri(), vec![make_admin("dr_a")]); + let config = config_with_llm(tmp.clone(), mock.uri()); let (tx, rx) = analyze::channel(); let handle = tokio::spawn(analyze::worker::run(rx, config, reqwest::Client::new())); @@ -613,7 +499,6 @@ async fn reanalyze_worker_writes_document_v2_via_wiremock() { let content = std::fs::read_to_string(&doc_v2).unwrap(); assert!(content.contains("neue Fassung")); - // v1 untouched. assert_eq!( std::fs::read_to_string(case_dir.join("document_v1.md")).unwrap(), "alte Version" @@ -623,6 +508,182 @@ async fn reanalyze_worker_writes_document_v2_via_wiremock() { let _ = tokio::time::timeout(Duration::from_secs(2), handle).await; } +// --------------------------------------------------------------------- +// Soft-delete + Undo +// --------------------------------------------------------------------- + +#[tokio::test] +async fn delete_writes_marker_and_redirects() { + let config = config_with_llm(unique_tmp("del-1"), "http://unused".into()); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.oneshot(delete_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + resp.headers().get(header::LOCATION).unwrap().to_str().unwrap(), + "/web/cases" + ); + let marker = case_dir.join(".deleted"); + assert!(marker.exists(), ".deleted marker missing"); + let raw = std::fs::read_to_string(&marker).unwrap(); + let parsed: Value = serde_json::from_str(&raw).unwrap(); + assert!(parsed["batch"].is_string(), "batch must be a UUID string"); + assert!(parsed["deleted_at"].is_string(), "deleted_at must be a string"); +} + +#[tokio::test] +async fn deleted_case_returns_404_on_detail() { + let config = config_with_llm(unique_tmp("del-2"), "http://unused".into()); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_recording(&case_dir, "10-00-00", Some("ok")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app.clone().oneshot(delete_request(case_id, &cookie)).await.unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + + let resp = app + .oneshot( + Request::builder() + .uri(format!("/web/cases/{case_id}")) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn undo_delete_restores_latest_batch_only() { + let config = config_with_llm(unique_tmp("undo-1"), "http://unused".into()); + let case_a = "11111111-1111-1111-1111-111111111111"; + let case_b = "22222222-2222-2222-2222-222222222222"; + let dir_a = seed_case(&config.data_path, "dr_a", case_a); + let dir_b = seed_case(&config.data_path, "dr_a", case_b); + seed_recording(&dir_a, "10-00-00", Some("a")); + seed_recording(&dir_b, "10-05-00", Some("b")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + // Two separate delete clicks → two distinct batches. + let _ = app.clone().oneshot(delete_request(case_a, &cookie)).await.unwrap(); + // Ensure timestamps differ at sub-second resolution (RFC3339 includes + // fractional seconds via the Rfc3339 well-known format). + tokio::time::sleep(Duration::from_millis(20)).await; + let _ = app.clone().oneshot(delete_request(case_b, &cookie)).await.unwrap(); + + let undo = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/web/cases/undo-delete") + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(undo.status(), StatusCode::SEE_OTHER); + + // case_b (latest delete) restored, case_a still deleted. + assert!(!dir_b.join(".deleted").exists(), "case_b marker should be gone"); + assert!(dir_a.join(".deleted").exists(), "case_a marker must remain"); +} + +#[tokio::test] +async fn bulk_delete_shares_one_batch_uuid() { + let config = config_with_llm(unique_tmp("bulk-d"), "http://unused".into()); + let case_a = "11111111-1111-1111-1111-111111111111"; + let case_b = "22222222-2222-2222-2222-222222222222"; + let dir_a = seed_case(&config.data_path, "dr_a", case_a); + let dir_b = seed_case(&config.data_path, "dr_a", case_b); + seed_recording(&dir_a, "10-00-00", Some("a")); + seed_recording(&dir_b, "10-05-00", Some("b")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let body = format!("action=delete&case_id={case_a}&case_id={case_b}"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/web/cases/bulk") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + + let m_a: Value = serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap(); + let m_b: Value = serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap(); + assert_eq!(m_a["batch"], m_b["batch"], "bulk-delete must share batch UUID"); +} + +#[tokio::test] +async fn bulk_analyze_processes_each_selected_case() { + let config = config_with_llm(unique_tmp("bulk-a"), "http://unused".into()); + let case_a = "11111111-1111-1111-1111-111111111111"; + let case_b = "22222222-2222-2222-2222-222222222222"; + let dir_a = seed_case(&config.data_path, "dr_a", case_a); + let dir_b = seed_case(&config.data_path, "dr_a", case_b); + seed_recording(&dir_a, "10-00-00", Some("a")); + seed_recording(&dir_b, "10-05-00", Some("b")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let body = format!("action=analyze&case_id={case_a}&case_id={case_b}"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/web/cases/bulk") + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + + assert!(dir_a.join("analysis_input_v1.json").exists()); + assert!(dir_b.join("analysis_input_v1.json").exists()); +} + +#[tokio::test] +async fn recovery_skips_deleted_cases() { + let tmp = unique_tmp("rec-del"); + let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111"); + std::fs::create_dir_all(&case_dir).unwrap(); + std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap(); + std::fs::write( + case_dir.join(".deleted"), + r#"{"batch":"00000000-0000-0000-0000-000000000000","deleted_at":"2026-04-15T10:00:00Z"}"#, + ) + .unwrap(); + + let (tx, mut rx) = analyze::channel(); + analyze::recovery::scan_and_enqueue(&tmp, &tx).await; + + assert!(rx.try_recv().is_err(), "deleted case must not be enqueued"); +} + #[tokio::test] async fn document_view_without_document_returns_404() { let config = config_with_llm(unique_tmp("dv2"), "http://unused".into());