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:
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user