Add admin-only case reset functionality

Introduce a new "reset" action for bulk operations and a dedicated
endpoint for individual case resets. These features allow administrators
to revert a case to its raw audio state by deleting derived artifacts
like transcripts, analysis input, and documents. The functionality also
includes renaming `.m4a.failed` files back to `.m4a` to re-trigger
transcription.

This commit also:
- Adds a `reset_case_artefacts` helper function to `case_actions.rs`.
- Implements the `handle_reset_case` endpoint.
- Adds the "Reset" button to the UI for administrators.
- Includes unit and integration tests to verify the new functionality
  and access control.
This commit is contained in:
2026-04-16 11:27:55 +02:00
parent bbc9b53609
commit b2b1368902
8 changed files with 261 additions and 2 deletions
+153
View File
@@ -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> {
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<Body> {
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());
}