Files
doctate/server/src/routes/bulk.rs
T
Brummel 1d702e2d85 Add event bus for live UI updates
Introduces a system for broadcasting real-time events to the web UI.
This enables features like automatic page reloads when case data changes
in the background, improving the user experience by keeping the UI
synchronized with the backend.

Key components:
- `events` module: Contains the `CaseEventKind` enum, `CaseEvent`
  struct, and `EventSender` type for managing the broadcast channel.
- SSE endpoint (`/web/events`): Streams events to connected browsers.
- Client-side JavaScript: Listens for events and triggers debounced page
  reloads.
- Integration points: Workers and route handlers now emit events when
  relevant state changes occur.
2026-04-19 23:33:53 +02:00

210 lines
7.1 KiB
Rust

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=<uuid>` form fields. Empty for an empty selection
/// (handled as a no-op).
#[serde(default, rename = "case_id")]
case_ids: Vec<String>,
}
/// 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<Arc<Config>>,
State(analyze_tx): State<AnalyzeSender>,
State(events_tx): State<EventSender>,
Form(form): Form<BulkForm>,
) -> Result<Redirect, AppError> {
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");
}