diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index 3f7e656..f7b552c 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -13,7 +13,7 @@ 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, write_input_create_new}; +use crate::routes::case_actions::{build_analysis_input, reset_case_artefacts, write_input_create_new}; use crate::routes::user_web::locate_case; #[derive(Debug, Deserialize)] @@ -39,6 +39,13 @@ pub async fn handle_bulk_action( 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, + "reset" => { + if !user.is_admin() { + warn!(slug = %user.slug, "bulk-reset: not admin"); + return Err(AppError::Forbidden("Nur für Admins".into())); + } + bulk_reset(&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())); @@ -148,3 +155,27 @@ async fn bulk_delete(slug: &str, user_root: &std::path::Path, case_ids: &[String } info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed"); } + +async fn bulk_reset(slug: &str, user_root: &std::path::Path, case_ids: &[String]) { + 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-reset: 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-reset: case not found"); + skipped += 1; + continue; + }; + if let Err(e) = reset_case_artefacts(&case_dir).await { + warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-reset: failed"); + skipped += 1; + continue; + } + ok += 1; + } + info!(slug = %slug, ok, skipped, "bulk-reset completed"); +} diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index f0df5ba..e6054e0 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -243,6 +243,32 @@ pub(crate) async fn read_document(case_dir: &Path) -> Option { tokio::fs::read_to_string(case_dir.join(DOCUMENT_FILE)).await.ok() } +/// Reset a case to its raw audio: delete all derived artefacts +/// (transcripts, oneliner, analysis input, document) and rename every +/// `*.m4a.failed` back to `*.m4a` so the transcribe self-heal picks it +/// up again. Idempotent: missing files are no-ops. +pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> { + for name in [DOCUMENT_FILE, ANALYSIS_INPUT_FILE, "oneliner.txt"] { + match tokio::fs::remove_file(case_dir.join(name)).await { + Ok(_) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e), + } + } + let mut entries = tokio::fs::read_dir(case_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let Some(s) = name.to_str() else { continue }; + if s.ends_with(".transcript.txt") { + tokio::fs::remove_file(entry.path()).await?; + } else if s.ends_with(".m4a.failed") { + let new_name = &s[..s.len() - ".failed".len()]; + tokio::fs::rename(entry.path(), case_dir.join(new_name)).await?; + } + } + Ok(()) +} + /// POST /web/cases/{case_id}/delete /// /// Soft-delete: writes a `.deleted` JSON marker into the case directory. @@ -282,6 +308,41 @@ pub async fn handle_delete_case( Ok(Redirect::to("/web/cases")) } +/// POST /web/cases/{case_id}/reset +/// +/// Admin-only. Wipes all derived artefacts (transcripts, oneliner, +/// analysis input, document) and un-fails audio (`.m4a.failed` → `.m4a`) +/// so the pipeline starts over on the raw recordings. Best-effort: a +/// currently-running worker may race and write a fresh artefact back; +/// admin can click Reset again. +pub async fn handle_reset_case( + user: AuthenticatedWebUser, + State(config): State>, + AxumPath(case_id): AxumPath, +) -> Result { + if !user.is_admin() { + return Err(AppError::Forbidden("Nur für Admins".into())); + } + 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, "reset: case not found"); + return Err(AppError::NotFound("Case not found".into())); + } + }; + + reset_case_artefacts(&case_dir) + .await + .map_err(|e| AppError::Internal(format!("reset: {e}")))?; + + info!(slug = %user.slug, case_id = %case_id, "case reset (admin)"); + Ok(Redirect::to("/web/cases")) +} + /// POST /web/cases/undo-delete /// /// Restore every case in the most recent delete batch by removing its diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 0f6f528..2d7a848 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -29,6 +29,10 @@ pub fn api_router() -> Router { "/web/cases/{case_id}/delete", post(case_actions::handle_delete_case), ) + .route( + "/web/cases/{case_id}/reset", + post(case_actions::handle_reset_case), + ) .route( "/web/cases/undo-delete", post(case_actions::handle_undo_delete), diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 8c3f294..4b6490f 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -133,6 +133,9 @@ struct CaseDetailTemplate { /// per-recording "Transkription läuft…" label — suppresses the lie when /// a transcript is missing but no worker is active. transcribe_busy: bool, + /// True iff the session user is an admin — toggles admin-only controls + /// (currently the Reset button). + is_admin: bool, } /// Flags derived from filesystem state + config. @@ -291,6 +294,7 @@ pub async fn handle_case_detail( let t_busy = transcribe_busy.0.load(Ordering::Acquire); let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await; let case_id_short = case_id.chars().take(8).collect(); + let is_admin = user.is_admin(); CaseDetailTemplate { slug: user.slug, @@ -303,6 +307,7 @@ pub async fn handle_case_detail( analyzing: flags.analyzing, has_document: flags.has_document, transcribe_busy: t_busy, + is_admin, } .render() .map(Html) diff --git a/server/templates/case_detail.html b/server/templates/case_detail.html index 7868f08..0bdc976 100644 --- a/server/templates/case_detail.html +++ b/server/templates/case_detail.html @@ -50,6 +50,9 @@ header form { margin: 0; } {% else if llm_missing && !has_document %} LLM-Analyse nicht konfiguriert {% endif %} +{% if is_admin %} +
+{% endif %}
diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 4d6e459..663e7dd 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -75,6 +75,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
+{% if is_admin %}{% endif %}
@@ -85,6 +86,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
Auswahl: +{% if is_admin %}{% endif %}
diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index c19b844..8694265 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -34,6 +34,12 @@ fn make_user(slug: &str) -> User { } } +fn make_admin(slug: &str) -> User { + let mut u = make_user(slug); + u.role = "admin".into(); + u +} + fn config_with_llm(data_path: PathBuf, llm_url: String) -> Arc { config_with_llm_users(data_path, llm_url, vec![make_user("dr_a")]) } @@ -644,3 +650,150 @@ async fn document_view_without_document_returns_404() { .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + +// --------------------------------------------------------------------- +// Admin-only reset +// --------------------------------------------------------------------- + +fn reset_request(case_id: &str, cookie: &str) -> Request { + Request::builder() + .method("POST") + .uri(format!("/web/cases/{case_id}/reset")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .unwrap() +} + +#[tokio::test] +async fn reset_case_clears_derived_and_unfails_audio() { + let tmp = unique_tmp("reset-admin"); + let config = config_with_llm_users( + tmp.clone(), + "http://unused".into(), + vec![make_admin("dr_a")], + ); + let case_id = "11111111-1111-1111-1111-111111111111"; + let dir = seed_case(&config.data_path, "dr_a", case_id); + + // Good recording with transcript. + seed_recording(&dir, "09-00-00", Some("hallo")); + // A failed recording: raw audio written, but filename carries `.failed`. + std::fs::write(dir.join("2026-04-15T09-05-00Z.m4a.failed"), b"audio-bytes").unwrap(); + // Derived artefacts the reset must wipe. + std::fs::write(dir.join("oneliner.txt"), "Knie re.").unwrap(); + std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); + std::fs::write(dir.join("analysis_input.json"), "{}").unwrap(); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app + .oneshot(reset_request(case_id, &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert_eq!( + resp.headers().get(header::LOCATION).unwrap(), + "/web/cases" + ); + + // Audio preserved, .failed renamed to .m4a. + assert!(dir.join("2026-04-15T09-00-00Z.m4a").exists()); + assert!(dir.join("2026-04-15T09-05-00Z.m4a").exists()); + assert!(!dir.join("2026-04-15T09-05-00Z.m4a.failed").exists()); + // Derived artefacts gone. + assert!(!dir.join("2026-04-15T09-00-00Z.transcript.txt").exists()); + assert!(!dir.join("oneliner.txt").exists()); + assert!(!dir.join("document.md").exists()); + assert!(!dir.join("analysis_input.json").exists()); +} + +#[tokio::test] +async fn reset_case_requires_admin() { + let tmp = unique_tmp("reset-nonadmin"); + // Default `make_user` has role=doctor, not admin. + let config = config_with_llm(tmp.clone(), "http://unused".into()); + let case_id = "11111111-1111-1111-1111-111111111111"; + let dir = seed_case(&config.data_path, "dr_a", case_id); + seed_recording(&dir, "09-00-00", Some("hallo")); + std::fs::write(dir.join("document.md"), "# Doc\n").unwrap(); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let resp = app + .oneshot(reset_request(case_id, &cookie)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // Nothing touched. + assert!(dir.join("2026-04-15T09-00-00Z.transcript.txt").exists()); + assert!(dir.join("document.md").exists()); +} + +#[tokio::test] +async fn bulk_reset_processes_multiple_cases_admin_only() { + let tmp = unique_tmp("bulk-reset"); + let config = config_with_llm_users( + tmp.clone(), + "http://unused".into(), + vec![make_admin("dr_a"), make_user("dr_b")], + ); + 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")); + std::fs::write(dir_a.join("document.md"), "A").unwrap(); + std::fs::write(dir_b.join("document.md"), "B").unwrap(); + // dr_b gets its own case to verify non-admin branch without clobbering dr_a. + let case_c = "33333333-3333-3333-3333-333333333333"; + let dir_c = seed_case(&config.data_path, "dr_b", case_c); + seed_recording(&dir_c, "11-00-00", Some("c")); + std::fs::write(dir_c.join("document.md"), "C").unwrap(); + + let app = doctate_server::create_router(config); + + // Admin: bulk reset succeeds. + let cookie_admin = login(app.clone(), "dr_a").await; + let body = format!("action=reset&case_id={case_a}&case_id={case_b}"); + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/web/cases/bulk") + .header(header::COOKIE, &cookie_admin) + .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("document.md").exists()); + assert!(!dir_b.join("document.md").exists()); + assert!(!dir_a.join("2026-04-15T10-00-00Z.transcript.txt").exists()); + assert!(!dir_b.join("2026-04-15T10-05-00Z.transcript.txt").exists()); + + // Non-admin: bulk reset is rejected, dr_b's case untouched. + let cookie_doctor = login(app.clone(), "dr_b").await; + let body = format!("action=reset&case_id={case_c}"); + let resp = app + .oneshot( + Request::builder() + .method("POST") + .uri("/web/cases/bulk") + .header(header::COOKIE, &cookie_doctor) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + assert!(dir_c.join("document.md").exists()); + assert!(dir_c.join("2026-04-15T11-00-00Z.transcript.txt").exists()); +} diff --git a/whisper/docker-compose.yml b/whisper/docker-compose.yml index 3c6887e..7cf56c4 100644 --- a/whisper/docker-compose.yml +++ b/whisper/docker-compose.yml @@ -3,7 +3,7 @@ services: image: doctate-whisper:latest container_name: doctate-whisper environment: - - WHISPER_MODEL=large-v3-turbo + - WHISPER_MODEL=large-v3 - WHISPER_COMPUTE_TYPE=float16 - NVIDIA_VISIBLE_DEVICES=all - NVIDIA_DRIVER_CAPABILITIES=compute,utility