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.
This commit is contained in:
2026-04-15 23:06:31 +02:00
parent 36b3e5f6c1
commit 0b63d8ff56
12 changed files with 662 additions and 288 deletions
+48 -30
View File
@@ -16,9 +16,13 @@ struct UserCaseView {
case_id_short: String,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
recordings_count: usize,
analyzing: bool,
has_document: bool,
/// True iff the inline "Analysieren"-button should be enabled.
/// Requires: not currently analyzing, all non-failed recordings
/// transcribed, and an LLM is configured.
can_analyze: bool,
}
#[derive(Template)]
@@ -26,6 +30,9 @@ struct UserCaseView {
struct MyCasesTemplate {
slug: String,
cases: Vec<UserCaseView>,
/// Number of cases that "Undo last delete" would restore. 0 hides the
/// undo button entirely.
undo_count: usize,
}
#[derive(Template)]
@@ -36,30 +43,28 @@ struct CaseDetailTemplate {
case_id_short: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
can_close: bool,
can_analyze: bool,
llm_missing: bool,
analyzing: bool,
has_document: bool,
can_reanalyze: bool,
}
/// Flags derived from filesystem state + config. Priority order
/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`,
/// then `llm_missing`. If none is true, the case is still receiving
/// recordings or has zero transcripts; no action surfaced.
/// Flags derived from filesystem state + config.
/// `can_analyze` covers both first analysis and re-analysis — same precondition
/// (all non-failed recordings transcribed, LLM configured, no analysis in
/// flight). `llm_missing` is shown to the user as an explanation when an
/// analysis would otherwise be possible but no LLM is configured.
struct CaseFlags {
has_document: bool,
analyzing: bool,
can_close: bool,
can_analyze: bool,
llm_missing: bool,
can_reanalyze: bool,
}
async fn compute_flags(
case_dir: &Path,
recordings: &[RecordingView],
llm_configured: bool,
is_admin: bool,
) -> CaseFlags {
let has_document = any_document_exists(case_dir).await;
let analyzing = !has_document
@@ -71,14 +76,13 @@ async fn compute_flags(
let all_transcribed =
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
let transcribed_stage = !has_document && !analyzing && all_transcribed;
let analyzable = !analyzing && all_transcribed;
CaseFlags {
has_document,
analyzing,
can_close: transcribed_stage && llm_configured,
llm_missing: transcribed_stage && !llm_configured,
can_reanalyze: has_document && is_admin && llm_configured,
can_analyze: analyzable && llm_configured,
llm_missing: analyzable && !llm_configured,
}
}
@@ -103,10 +107,16 @@ pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
) -> Result<Html<String>, AppError> {
let cases = scan_user_cases(&config.data_path, &user.slug).await;
let cases = scan_user_cases(&config, &user.slug).await;
let user_root = config.data_path.join(&user.slug);
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await
.map(|(_, n)| n)
.unwrap_or(0);
MyCasesTemplate {
slug: user.slug,
cases,
undo_count,
}
.render()
.map(Html)
@@ -139,13 +149,7 @@ pub async fn handle_case_detail(
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let flags = compute_flags(
&case_dir,
&recordings,
config.llm_configured(),
user.is_admin(),
)
.await;
let flags = compute_flags(&case_dir, &recordings, config.llm_configured()).await;
let case_id_short = case_id.chars().take(8).collect();
CaseDetailTemplate {
@@ -154,11 +158,10 @@ pub async fn handle_case_detail(
case_id_short,
oneliner,
recordings,
can_close: flags.can_close,
can_analyze: flags.can_analyze,
llm_missing: flags.llm_missing,
analyzing: flags.analyzing,
has_document: flags.has_document,
can_reanalyze: flags.can_reanalyze,
}
.render()
.map(Html)
@@ -181,9 +184,11 @@ pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::
/// Scan all of the given user's cases. Returned vec is sorted by most
/// recent recording filename (lexicographic ≈ chronological since
/// timestamps are ISO-8601), newest first.
async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
let user_root = data_path.join(slug);
/// timestamps are ISO-8601), newest first. Soft-deleted cases (with
/// `.deleted` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str) -> Vec<UserCaseView> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
let mut cases = Vec::new();
let mut entries = match tokio::fs::read_dir(&user_root).await {
@@ -200,11 +205,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
if !case_entry.path().is_dir() {
let case_path = case_entry.path();
if !case_path.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_path).await {
continue;
}
let case_path = case_entry.path();
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
@@ -214,6 +222,14 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let recordings_count = recordings.len();
let non_failed_count = recordings.iter().filter(|r| !r.failed).count();
let all_transcribed = non_failed_count > 0
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
let case_id_short = case_id.chars().take(8).collect();
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
@@ -226,15 +242,17 @@ async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
&& tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
.await
.unwrap_or(false);
let can_analyze = !analyzing && all_transcribed && llm_configured;
cases.push(UserCaseView {
case_id,
case_id_short,
most_recent,
oneliner,
recordings,
recordings_count,
analyzing,
has_document,
can_analyze,
});
}