use std::str::FromStr; use std::sync::Arc; use axum::extract::State; use axum::response::Redirect; use doctate_common::BulkAction; use doctate_common::timestamp::now_rfc3339; use serde::Deserialize; use tracing::{info, warn}; use crate::analyze::backend::any_backend_available; use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::csrf::{CsrfForm, HasCsrfToken}; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; use crate::oneliner_locks::OnelinerLocks; use crate::paths::{CloseMarker, write_close_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, #[serde(default)] csrf_token: String, } impl HasCsrfToken for BulkForm { fn csrf_token(&self) -> &str { &self.csrf_token } } /// POST /web/cases/bulk — apply `action` (analyze | close | reset) 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, State(locks): State, CsrfForm(form): CsrfForm, ) -> Result { // Bulk actions are admin-only across the board: the UI hides the // selection checkboxes and action bar from non-admins, and this // server-side gate defends against hand-crafted POSTs. if !user.is_admin() { warn!(slug = %user.slug, action = %form.action, "bulk: not admin"); return Err(AppError::Forbidden("Nur für Admins".into())); } let user_root = config.data_path.join(&user.slug); // Parse the wire token into the shared enum so the match below is // exhaustive at compile time. Keep the exact "Unbekannte Aktion" // error shape the handler returned before the enum existed. let action = BulkAction::from_str(&form.action).map_err(|err| { warn!(slug = %user.slug, action = %err.0, "bulk: unknown action"); AppError::BadRequest("Unbekannte Aktion".into()) })?; match action { BulkAction::Analyze => { bulk_analyze( &analyze_tx, &events_tx, &user.slug, &user_root, &form.case_ids, ) .await } BulkAction::Close => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await, BulkAction::Reset => { bulk_reset(&events_tx, &locks, &user.slug, &user_root, &form.case_ids).await } } Ok(Redirect::to("/web/cases")) } async fn bulk_analyze( analyze_tx: &AnalyzeSender, events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { if !any_backend_available() { warn!(slug = %slug, "bulk-analyze: no LLM backend available, 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_close( events_tx: &EventSender, slug: &str, user_root: &std::path::Path, case_ids: &[String], ) { let closed_at = now_rfc3339(); 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-close: 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-close: case not found"); skipped += 1; continue; }; let marker = CloseMarker { closed_at: closed_at.clone(), }; if let Err(e) = write_close_marker(&case_dir, &marker).await { warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-close: write marker failed"); skipped += 1; continue; } events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed); ok += 1; } info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-close completed"); } async fn bulk_reset( events_tx: &EventSender, locks: &OnelinerLocks, 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, locks).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"); }