use std::sync::Arc; use axum::extract::State; use axum::response::Redirect; use axum_extra::extract::Form; use serde::Deserialize; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tracing::{info, warn}; use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; use crate::paths::{DeleteMarker, write_delete_marker}; 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)] pub struct BulkForm { action: String, /// Repeated `case_id=` form fields. Empty for an empty selection /// (handled as a no-op). #[serde(default, rename = "case_id")] case_ids: Vec, } /// POST /web/cases/bulk — apply `action` (analyze | delete) to every /// selected case. Errors per case are logged but do not abort the batch: /// a single bad/missing case must not block the rest. pub async fn handle_bulk_action( user: AuthenticatedWebUser, State(config): State>, State(analyze_tx): State, State(events_tx): State, Form(form): Form, ) -> Result { let user_root = config.data_path.join(&user.slug); match form.action.as_str() { "analyze" => { bulk_analyze( &config, &analyze_tx, &events_tx, &user.slug, &user_root, &form.case_ids, ) .await } "delete" => bulk_delete(&events_tx, &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(&events_tx, &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())); } } Ok(Redirect::to("/web/cases")) } async fn bulk_analyze( config: &Config, analyze_tx: &AnalyzeSender, events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { if !config.llm_configured() { warn!(slug = %slug, "bulk-analyze: LLM not configured, skipping all"); return; } 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-analyze: 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-analyze: case not found"); skipped += 1; continue; }; // Same order as the single-case handler: drop old document first so // the UI doesn't keep showing "ausgewertet" during re-analysis. let document_path = case_dir.join(DOCUMENT_FILE); match tokio::fs::remove_file(&document_path).await { Ok(_) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => { warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-analyze: remove old document failed"); skipped += 1; continue; } } let input = match build_analysis_input(&case_dir).await { Ok(i) => i, Err(_) => { warn!(slug = %slug, case_id = %case_id, "bulk-analyze: build_analysis_input failed"); skipped += 1; continue; } }; let input_path = case_dir.join(ANALYSIS_INPUT_FILE); if write_input_create_new(&input_path, &input).await.is_err() { warn!(slug = %slug, case_id = %case_id, "bulk-analyze: write input failed"); skipped += 1; continue; } if analyze_tx .send(AnalyzeJob { case_dir: case_dir.clone(), }) .is_err() { warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)"); } events::emit(events_tx, slug, case_id, CaseEventKind::AnalysisQueued); ok += 1; } info!(slug = %slug, ok, skipped, "bulk-analyze completed"); } async fn bulk_delete( events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { // One batch UUID + one timestamp shared across the entire bulk action, // so undo can restore exactly this group. let batch = uuid::Uuid::new_v4(); let deleted_at = match OffsetDateTime::now_utc().format(&Rfc3339) { Ok(s) => s, Err(e) => { warn!(slug = %slug, error = %e, "bulk-delete: failed to format timestamp"); return; } }; 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-delete: 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-delete: case not found"); skipped += 1; continue; }; let marker = DeleteMarker { batch, deleted_at: deleted_at.clone(), }; if let Err(e) = write_delete_marker(&case_dir, &marker).await { warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-delete: write marker failed"); skipped += 1; continue; } events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted); ok += 1; } info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed"); } async fn bulk_reset( events_tx: &EventSender, 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; } events::emit(events_tx, slug, case_id, CaseEventKind::CaseReset); ok += 1; } info!(slug = %slug, ok, skipped, "bulk-reset completed"); }