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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -243,6 +243,32 @@ pub(crate) async fn read_document(case_dir: &Path) -> Option<String> {
|
||||
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<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
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
|
||||
|
||||
@@ -29,6 +29,10 @@ pub fn api_router() -> Router<AppState> {
|
||||
"/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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,6 +50,9 @@ header form { margin: 0; }
|
||||
{% else if llm_missing && !has_document %}
|
||||
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/reset" style="display:inline"><button type="submit">Reset</button></form>
|
||||
{% endif %}
|
||||
<form method="post" action="/web/cases/{{ case_id }}/delete" style="margin-left:auto;display:inline"><button type="submit">Entfernen</button></form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" formaction="/web/cases/{{ case.case_id }}/analyze"{% if !case.can_analyze %} disabled{% endif %}>{% if case.has_document %}Neu analysieren{% else %}Analysieren{% endif %}</button>
|
||||
{% if is_admin %}<button type="submit" formaction="/web/cases/{{ case.case_id }}/reset">Reset</button>{% endif %}
|
||||
<button type="submit" formaction="/web/cases/{{ case.case_id }}/delete">Entfernen</button>
|
||||
</div>
|
||||
</li>
|
||||
@@ -85,6 +86,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
||||
<div class="bulk-bar">
|
||||
<span>Auswahl:</span>
|
||||
<button type="submit" name="action" value="analyze">Analysieren</button>
|
||||
{% if is_admin %}<button type="submit" name="action" value="reset">Reset</button>{% endif %}
|
||||
<button type="submit" name="action" value="delete">Entfernen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user