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
+32 -1
View File
@@ -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");
}
+61
View File
@@ -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
+4
View File
@@ -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),
+5
View File
@@ -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)