Files
doctate/server/src/routes/web.rs
T
Brummel 0b63d8ff56 Feat: Add bulk actions for case analysis and deletion
Introduces a new `/web/cases/bulk` endpoint to handle multiple case
actions simultaneously.

This change adds the following:
- A new `bulk` module for handling bulk operations.
- The `handle_bulk_action` function in `bulk.rs` to dispatch to specific
  actions.
- `bulk_analyze` and `bulk_delete` functions to process the actions.
- Updates `Cargo.toml` and `Cargo.lock` to include necessary
  dependencies: `form_urlencoded`, `serde_core`, `serde_html_form`, and
  `serde_path_to_error`.
- Modified `axum-extra` features to include `form`.
- Adds checks for deleted cases in `collect_pending` for both analysis
  and transcription recovery.
- Renames and modifies `handle_close_case` to `handle_analyze_case` in
  `case_actions.rs` to better reflect its functionality.
- Adds a new `handle_delete_case` and `handle_undo_delete` to
  `case_actions.rs`.
- Updates `my_cases.html` and `case_detail.html` to support bulk
  actions, including checkboxes, a bulk action bar, and an "Undo last
  delete" feature.
- Modifies `handle_audio` and `scan_cases` in `web.rs` to respect delete
  markers.
- Updates `analyze_test.rs` with new request builders for analyze and
  delete actions.
2026-04-15 23:06:31 +02:00

205 lines
6.5 KiB
Rust

use std::path::Path;
use std::sync::Arc;
use askama::Template;
use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::header;
use axum::response::{Html, Response};
use tracing::warn;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::user_web::any_document_exists;
pub(crate) struct RecordingView {
pub(crate) filename: String,
pub(crate) transcript: Option<String>,
/// True if the file has been renamed to `<ts>.m4a.failed` by the worker
/// after a non-recoverable ffmpeg or whisper error.
pub(crate) failed: bool,
}
struct CaseView {
user_slug: String,
case_id: String,
case_id_short: String,
has_document: bool,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
}
#[derive(Template)]
#[template(path = "cases.html")]
struct CasesTemplate {
cases: Vec<CaseView>,
}
pub async fn handle_case_list(
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
let cases = scan_cases(&config.data_path).await;
let template = CasesTemplate { cases };
template
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
pub async fn handle_audio(
State(config): State<Arc<Config>>,
AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
) -> Result<Response, AppError> {
validate_user_slug(&user)?;
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
validate_filename(&filename)?;
let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id);
if crate::paths::is_deleted(&case_path).await {
return Err(AppError::NotFound("Audio file not found".into()));
}
let file_path = case_path.join(&filename);
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::NotFound("Audio file not found".into()));
}
let bytes = tokio::fs::read(&file_path).await?;
Response::builder()
.header(header::CONTENT_TYPE, "audio/mp4")
.body(Body::from(bytes))
.map_err(|e| AppError::Internal(e.to_string()))
}
fn validate_user_slug(user: &str) -> Result<(), AppError> {
if user.is_empty() || user.contains('/') || user.contains('\\') || user.contains("..") {
return Err(AppError::BadRequest("Invalid user slug".into()));
}
Ok(())
}
fn validate_filename(filename: &str) -> Result<(), AppError> {
let ok_suffix = filename.ends_with(".m4a") || filename.ends_with(".m4a.failed");
if !ok_suffix
|| filename.contains('/')
|| filename.contains('\\')
|| filename.contains("..")
{
return Err(AppError::BadRequest("Invalid filename".into()));
}
Ok(())
}
/// Scan the data directory for all cases across all users.
/// Silently skips invalid entries so a single broken directory does not break the page.
async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
let mut cases = Vec::new();
let mut users = match tokio::fs::read_dir(data_path).await {
Ok(r) => r,
Err(_) => return cases, // data_path may not exist yet
};
while let Ok(Some(user_entry)) = users.next_entry().await {
let user_slug = match user_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if validate_user_slug(&user_slug).is_err() {
continue;
}
if !user_entry.path().is_dir() {
continue;
}
let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await {
Ok(r) => r,
Err(_) => continue,
};
while let Ok(Some(case_entry)) = case_entries.next_entry().await {
let case_id = match case_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
if !case_entry.path().is_dir() {
continue;
}
let case_path = case_entry.path();
if crate::paths::is_deleted(&case_path).await {
continue;
}
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
}
let most_recent = recordings
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let case_id_short = case_id.chars().take(8).collect();
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let has_document = any_document_exists(&case_path).await;
cases.push(CaseView {
user_slug: user_slug.clone(),
case_id,
case_id_short,
has_document,
most_recent,
oneliner,
recordings,
});
}
}
// Most recent case first.
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}
pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
let mut recordings = Vec::new();
let mut entries = match tokio::fs::read_dir(case_dir).await {
Ok(r) => r,
Err(_) => return recordings,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let filename = match entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
let failed = filename.ends_with(".m4a.failed");
let is_audio = filename.ends_with(".m4a") || failed;
if !is_audio {
continue;
}
// Transcript naming uses the original `<ts>.m4a` stem in both cases
// (failed recordings never produced one, but the lookup still works).
let stem_path = case_dir.join(filename.trim_end_matches(".failed"));
let transcript_path = stem_path.with_extension("transcript.txt");
let transcript = tokio::fs::read_to_string(&transcript_path).await.ok();
recordings.push(RecordingView {
filename,
transcript,
failed,
});
}
recordings.sort_by(|a, b| a.filename.cmp(&b.filename));
recordings
}