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:
@@ -43,6 +43,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> {
|
||||
if !case_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if crate::paths::is_deleted(&case_dir).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut files = match tokio::fs::read_dir(&case_dir).await {
|
||||
Ok(r) => r,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::response::Redirect;
|
||||
use axum_extra::extract::Form;
|
||||
use serde::Deserialize;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::analyze::{AnalyzeJob, AnalyzeSender};
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::paths::{write_delete_marker, DeleteMarker};
|
||||
use crate::routes::case_actions::{build_analysis_input, find_latest_document, 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>,
|
||||
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, &user.slug, &user_root, &form.case_ids).await,
|
||||
"delete" => bulk_delete(&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,
|
||||
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;
|
||||
};
|
||||
let version = find_latest_document(&case_dir)
|
||||
.await
|
||||
.map(|(v, _)| v + 1)
|
||||
.unwrap_or(1);
|
||||
let input = match build_analysis_input(&case_dir, version).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(format!("analysis_input_v{version}.json"));
|
||||
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(),
|
||||
version,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-analyze: send failed (worker gone)");
|
||||
}
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, ok, skipped, "bulk-analyze completed");
|
||||
}
|
||||
|
||||
async fn bulk_delete(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;
|
||||
}
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed");
|
||||
}
|
||||
@@ -14,6 +14,7 @@ use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput};
|
||||
use crate::auth::AuthenticatedWebUser;
|
||||
use crate::config::Config;
|
||||
use crate::error::AppError;
|
||||
use crate::paths::{read_delete_marker, write_delete_marker, DeleteMarker, DELETE_MARKER};
|
||||
use crate::routes::user_web::locate_case;
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -24,12 +25,14 @@ struct DocumentTemplate {
|
||||
content: String,
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/close
|
||||
/// POST /web/cases/{case_id}/analyze
|
||||
///
|
||||
/// Persist an `analysis_input_v1.json` for the case and enqueue an analyze
|
||||
/// job. Redirects back to the case detail page where the UI will show
|
||||
/// "wird analysiert …" until the worker writes `document_v1.md`.
|
||||
pub async fn handle_close_case(
|
||||
/// Trigger an LLM analysis for the case. If no document exists yet, writes
|
||||
/// `analysis_input_v1.json`; otherwise writes `analysis_input_v{N+1}.json`
|
||||
/// where N is the current latest document version. The worker picks up the
|
||||
/// new input and produces `document_v{version}.md`. Available to all logged-in
|
||||
/// users (no admin check). Race protection via `create_new` on the input file.
|
||||
pub async fn handle_analyze_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
@@ -38,7 +41,6 @@ pub async fn handle_close_case(
|
||||
uuid::Uuid::parse_str(&case_id)
|
||||
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||
|
||||
// Server-side guard mirroring the UI gate: no close without a configured LLM.
|
||||
if !config.llm_configured() {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
"LLM-Analyse nicht konfiguriert".into(),
|
||||
@@ -49,20 +51,18 @@ pub async fn handle_close_case(
|
||||
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!(slug = %user.slug, case_id = %case_id, "close: case not found");
|
||||
warn!(slug = %user.slug, case_id = %case_id, "analyze: case not found");
|
||||
return Err(AppError::NotFound("Case not found".into()));
|
||||
}
|
||||
};
|
||||
|
||||
let input_path = case_dir.join("analysis_input_v1.json");
|
||||
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
|
||||
return Err(AppError::Conflict(
|
||||
"Analyse läuft bereits für diesen Fall".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let input = build_analysis_input(&case_dir, 1).await?;
|
||||
let version = find_latest_document(&case_dir)
|
||||
.await
|
||||
.map(|(v, _)| v + 1)
|
||||
.unwrap_or(1);
|
||||
|
||||
let input = build_analysis_input(&case_dir, version).await?;
|
||||
let input_path = case_dir.join(format!("analysis_input_v{version}.json"));
|
||||
write_input_create_new(&input_path, &input).await?;
|
||||
|
||||
// Unbounded send: only fails if the worker has gone away (programmer error
|
||||
@@ -71,7 +71,7 @@ pub async fn handle_close_case(
|
||||
if analyze_tx
|
||||
.send(AnalyzeJob {
|
||||
case_dir: case_dir.clone(),
|
||||
version: 1,
|
||||
version,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -81,76 +81,9 @@ pub async fn handle_close_case(
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
version,
|
||||
recordings = input.recordings.len(),
|
||||
"close requested; analysis enqueued"
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/reanalyze
|
||||
///
|
||||
/// Admin-only. Build a fresh `analysis_input_v{N+1}.json` from the current
|
||||
/// transcripts and enqueue a new analyze job. `find_latest_document` picks the
|
||||
/// currently-highest N; the worker will write `document_v{N+1}.md`, which then
|
||||
/// wins the "highest version" race and becomes the displayed document.
|
||||
pub async fn handle_reanalyze_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(analyze_tx): State<AnalyzeSender>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
uuid::Uuid::parse_str(&case_id)
|
||||
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||
|
||||
if !user.is_admin() {
|
||||
return Err(AppError::Forbidden("Admin erforderlich".into()));
|
||||
}
|
||||
|
||||
if !config.llm_configured() {
|
||||
return Err(AppError::ServiceUnavailable(
|
||||
"LLM-Analyse nicht konfiguriert".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found");
|
||||
return Err(AppError::NotFound("Case not found".into()));
|
||||
}
|
||||
};
|
||||
|
||||
let current_version = find_latest_document(&case_dir)
|
||||
.await
|
||||
.map(|(v, _)| v)
|
||||
.ok_or_else(|| {
|
||||
AppError::BadRequest("Kein bestehendes Dokument — nutze 'Fall abschließen'".into())
|
||||
})?;
|
||||
let next_version = current_version + 1;
|
||||
|
||||
let input = build_analysis_input(&case_dir, next_version).await?;
|
||||
|
||||
let input_path = case_dir.join(format!("analysis_input_v{next_version}.json"));
|
||||
write_input_create_new(&input_path, &input).await?;
|
||||
|
||||
if analyze_tx
|
||||
.send(AnalyzeJob {
|
||||
case_dir: case_dir.clone(),
|
||||
version: next_version,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
warn!(case_id = %case_id, "analyze send failed (worker gone)");
|
||||
}
|
||||
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
version = next_version,
|
||||
recordings = input.recordings.len(),
|
||||
"reanalyze requested; analysis enqueued"
|
||||
"analyze requested; analysis enqueued"
|
||||
);
|
||||
|
||||
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
|
||||
@@ -345,6 +278,152 @@ fn parse_document_version(filename: &str) -> Option<u32> {
|
||||
num.parse::<u32>().ok()
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/delete
|
||||
///
|
||||
/// Soft-delete: writes a `.deleted` JSON marker into the case directory.
|
||||
/// The case immediately disappears from listings and detail views (404).
|
||||
/// Reversible via `handle_undo_delete` while the marker still has the
|
||||
/// most recent `deleted_at` timestamp among the user's deleted cases.
|
||||
pub async fn handle_delete_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
AxumPath(case_id): AxumPath<String>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
uuid::Uuid::parse_str(&case_id)
|
||||
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
warn!(slug = %user.slug, case_id = %case_id, "delete: case not found");
|
||||
return Err(AppError::NotFound("Case not found".into()));
|
||||
}
|
||||
};
|
||||
|
||||
let marker = DeleteMarker {
|
||||
batch: uuid::Uuid::new_v4(),
|
||||
deleted_at: now_rfc3339()?,
|
||||
};
|
||||
write_delete_marker(&case_dir, &marker).await?;
|
||||
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
batch = %marker.batch,
|
||||
"case soft-deleted"
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
/// POST /web/cases/undo-delete
|
||||
///
|
||||
/// Restore every case in the most recent delete batch by removing its
|
||||
/// `.deleted` marker. "Most recent" = highest `deleted_at` across all
|
||||
/// markers under the user's data directory; ties broken by `batch`-UUID
|
||||
/// lexicographic order. No-op if no markers exist.
|
||||
pub async fn handle_undo_delete(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
|
||||
let latest = latest_delete_batch(&user_root).await;
|
||||
let Some((batch, _)) = latest else {
|
||||
info!(slug = %user.slug, "undo-delete: nothing to undo");
|
||||
return Ok(Redirect::to("/web/cases"));
|
||||
};
|
||||
|
||||
let restored = restore_batch(&user_root, batch).await;
|
||||
info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed");
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
/// Summarize the most recent delete batch under `user_root`: returns
|
||||
/// `(batch_uuid, count)` where count is the number of cases sharing that
|
||||
/// batch. `None` if no `.deleted` markers exist. Single directory scan.
|
||||
pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uuid, usize)> {
|
||||
let (batch, _) = latest_delete_batch(user_root).await?;
|
||||
let mut count = 0usize;
|
||||
let mut entries = tokio::fs::read_dir(user_root).await.ok()?;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let case_path = entry.path();
|
||||
if !case_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Some(marker) = read_delete_marker(&case_path).await
|
||||
&& marker.batch == batch
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Some((batch, count))
|
||||
}
|
||||
|
||||
/// Scan the user's data dir, return the `(batch, deleted_at)` of the
|
||||
/// most-recently-deleted case across all markers. `None` if no markers.
|
||||
async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> {
|
||||
let mut entries = tokio::fs::read_dir(user_root).await.ok()?;
|
||||
let mut best: Option<(uuid::Uuid, String)> = None;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(marker) = read_delete_marker(&entry.path()).await else {
|
||||
continue;
|
||||
};
|
||||
let candidate = (marker.batch, marker.deleted_at);
|
||||
best = match best {
|
||||
None => Some(candidate),
|
||||
// Order: deleted_at desc, then batch desc as tie-breaker.
|
||||
Some(ref cur)
|
||||
if candidate.1 > cur.1
|
||||
|| (candidate.1 == cur.1 && candidate.0 > cur.0) =>
|
||||
{
|
||||
Some(candidate)
|
||||
}
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Remove `.deleted` markers from every case whose marker carries `batch`.
|
||||
/// Returns the number of restored cases.
|
||||
async fn restore_batch(user_root: &Path, batch: uuid::Uuid) -> usize {
|
||||
let mut count = 0;
|
||||
let mut entries = match tokio::fs::read_dir(user_root).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let case_path = entry.path();
|
||||
if !case_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(marker) = read_delete_marker(&case_path).await else {
|
||||
continue;
|
||||
};
|
||||
if marker.batch != batch {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = tokio::fs::remove_file(case_path.join(DELETE_MARKER)).await {
|
||||
warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker");
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn now_rfc3339() -> Result<String, AppError> {
|
||||
OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| AppError::Internal(format!("format timestamp: {e}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod bulk;
|
||||
mod case_actions;
|
||||
mod debug;
|
||||
mod health;
|
||||
@@ -21,13 +22,18 @@ pub fn api_router() -> Router<AppState> {
|
||||
.route("/web/cases", get(user_web::handle_my_cases))
|
||||
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
|
||||
.route(
|
||||
"/web/cases/{case_id}/close",
|
||||
post(case_actions::handle_close_case),
|
||||
"/web/cases/{case_id}/analyze",
|
||||
post(case_actions::handle_analyze_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/reanalyze",
|
||||
post(case_actions::handle_reanalyze_case),
|
||||
"/web/cases/{case_id}/delete",
|
||||
post(case_actions::handle_delete_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/undo-delete",
|
||||
post(case_actions::handle_undo_delete),
|
||||
)
|
||||
.route("/web/cases/bulk", post(bulk::handle_bulk_action))
|
||||
.route(
|
||||
"/web/cases/{case_id}/document",
|
||||
get(case_actions::handle_document_view),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,11 @@ pub async fn handle_audio(
|
||||
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
|
||||
validate_filename(&filename)?;
|
||||
|
||||
let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&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()));
|
||||
}
|
||||
@@ -129,6 +133,9 @@ async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -55,6 +55,9 @@ async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> {
|
||||
if !case_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if crate::paths::is_deleted(&case_dir).await {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut files = match tokio::fs::read_dir(&case_dir).await {
|
||||
Ok(r) => r,
|
||||
|
||||
Reference in New Issue
Block a user