diff --git a/server/src/events.rs b/server/src/events.rs index f1a3cdb..5282771 100644 --- a/server/src/events.rs +++ b/server/src/events.rs @@ -32,6 +32,7 @@ pub enum CaseEventKind { DocumentReady, CaseClosed, CaseReopened, + CasePurged, CaseReset, } diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index dc0287d..3b67bfa 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -22,7 +22,7 @@ use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; -use crate::paths::{CLOSE_MARKER, CloseMarker, write_close_marker}; +use crate::paths::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker}; use crate::routes::user_web::locate_case_or_404; use crate::routes::web::validate_filename; @@ -394,6 +394,78 @@ pub async fn handle_reset_case( Ok(Redirect::to(&resolve_return_path(&headers))) } +#[derive(Deserialize)] +pub struct PurgeClosedForm { + // Optional so a missing field yields a clean 400 from our handler + // rather than a 422 from axum's form extractor. + #[serde(default)] + pub confirm: String, +} + +/// POST /web/cases/purge-closed +/// +/// Hard-delete every case under the user's data dir that carries a +/// `.closed` marker. Requires form field `confirm=yes` as a server-side +/// guard against accidental browser-history re-POSTs — the UI additionally +/// shows a JS confirm() dialog, but that can be defeated by re-submitting +/// the form from history, so the server double-checks. +/// +/// Per-case failures are logged but do not abort the sweep. Emits +/// `CasePurged` per successfully removed case. +pub async fn handle_purge_closed( + user: AuthenticatedWebUser, + State(config): State>, + State(events_tx): State, + Form(form): Form, +) -> Result { + if form.confirm != "yes" { + warn!(slug = %user.slug, "purge-closed: missing confirm=yes"); + return Err(AppError::BadRequest("Missing confirm=yes".into())); + } + + let user_root = config.data_path.join(&user.slug); + let mut ok = 0usize; + let mut skipped = 0usize; + let mut entries = match tokio::fs::read_dir(&user_root).await { + Ok(r) => r, + Err(e) => { + warn!(slug = %user.slug, error = %e, "purge-closed: read_dir failed"); + return Ok(Redirect::to("/web/cases")); + } + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let case_path = entry.path(); + if !case_path.is_dir() { + continue; + } + if !crate::paths::is_closed(&case_path).await { + continue; + } + // Read the marker up-front so the log line has context even + // if remove_dir_all later fails partway through. + let closed_at = read_close_marker(&case_path) + .await + .map(|m| m.closed_at) + .unwrap_or_else(|| "".to_string()); + let case_id = crate::events::case_id_of(&case_path); + if let Err(e) = tokio::fs::remove_dir_all(&case_path).await { + warn!(slug = %user.slug, case_id = %case_id, error = %e, "purge-closed: remove_dir_all failed"); + skipped += 1; + continue; + } + info!( + slug = %user.slug, + case_id = %case_id, + closed_at = %closed_at, + "case purged (manual bulk)" + ); + events::emit(&events_tx, &user.slug, case_id, CaseEventKind::CasePurged); + ok += 1; + } + info!(slug = %user.slug, ok, skipped, "purge-closed completed"); + Ok(Redirect::to("/web/cases")) +} + #[derive(Deserialize)] pub struct DeleteRecordingForm { pub filename: String, diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs index 9e9f583..8349af7 100644 --- a/server/src/routes/mod.rs +++ b/server/src/routes/mod.rs @@ -54,6 +54,10 @@ pub fn api_router() -> Router { "/web/cases/{case_id}/reset", post(case_actions::handle_reset_case), ) + .route( + "/web/cases/purge-closed", + post(case_actions::handle_purge_closed), + ) .route("/web/cases/bulk", post(bulk::handle_bulk_action)) .route( "/web/audio/{user}/{case_id}/{filename}", diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index f0f81e6..393c793 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -140,6 +140,17 @@ fn reopen_request(case_id: &str, cookie: &str) -> Request { .unwrap() } +fn purge_closed_request(cookie: &str, confirm: Option<&str>) -> Request { + let body = confirm.map(|v| format!("confirm={v}")).unwrap_or_default(); + Request::builder() + .method("POST") + .uri("/web/cases/purge-closed") + .header(header::COOKIE, cookie) + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from(body)) + .unwrap() +} + // --------------------------------------------------------------------- // HTTP-level precondition tests // --------------------------------------------------------------------- @@ -735,6 +746,90 @@ async fn reopen_is_noop_on_open_case() { assert!(!case_dir.join(".closed").exists()); } +#[tokio::test] +async fn purge_closed_removes_directory() { + let config = config_with_llm(unique_tmp("purge-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 _ = app + .clone() + .oneshot(close_request(case_id, &cookie)) + .await + .unwrap(); + assert!(case_dir.join(".closed").exists()); + + let resp = app + .oneshot(purge_closed_request(&cookie, Some("yes"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert!( + !case_dir.exists(), + "case directory must be removed after purge" + ); +} + +#[tokio::test] +async fn purge_requires_confirm_param() { + let config = config_with_llm(unique_tmp("purge-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 _ = app + .clone() + .oneshot(close_request(case_id, &cookie)) + .await + .unwrap(); + + let resp = app + .oneshot(purge_closed_request(&cookie, None)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert!( + case_dir.exists(), + "case directory must survive purge without confirm=yes" + ); + assert!(case_dir.join(".closed").exists()); +} + +#[tokio::test] +async fn purge_closed_keeps_open_cases() { + let config = config_with_llm(unique_tmp("purge-3"), "http://unused".into()); + let closed_id = "11111111-1111-1111-1111-111111111111"; + let open_id = "22222222-2222-2222-2222-222222222222"; + let dir_closed = seed_case(&config.data_path, "dr_a", closed_id); + let dir_open = seed_case(&config.data_path, "dr_a", open_id); + seed_recording(&dir_closed, "10-00-00", Some("a")); + seed_recording(&dir_open, "10-00-00", Some("b")); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_a").await; + + let _ = app + .clone() + .oneshot(close_request(closed_id, &cookie)) + .await + .unwrap(); + + let resp = app + .oneshot(purge_closed_request(&cookie, Some("yes"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + assert!(!dir_closed.exists(), "closed case must be purged"); + assert!(dir_open.exists(), "open case must survive the purge"); +} + #[tokio::test] async fn bulk_close_shares_one_closed_at_timestamp() { let config = config_with_llm(unique_tmp("bulk-c"), "http://unused".into());