feat: show-closed toggle, closed badge, reopen button, bulk purge

GET /web/cases now honours ?show_closed=1: the toggle link flips the
listing between open-only (default) and open+closed (muted styling,
grey "geschlossen" badge, countdown "wird in N Tagen entfernt" when
auto_delete_days > 0). Each closed row shows a Lucide rotate-ccw
reopen button in the same slot where the close button sits on open
cases; selection checkboxes are suppressed so bulk-close cannot touch
closed cases.

GET /web/cases/:id honours ?show_closed=1 too: without the query a
closed case still 404s (keeps the default listing clean), with it the
detail page renders the reopen form in the header. The case_page
template swaps the icon based on is_closed.

The purge-closed bulk button from the earlier commit finally gets a
UI: it appears below the listing when show_closed=1 is active AND at
least one closed case is visible, with a JS confirm() + the
server-side confirm=yes guard. Two integration tests (listing +
detail) cover the happy path.
This commit is contained in:
2026-04-21 10:58:00 +02:00
parent f358da215d
commit 23e828df45
4 changed files with 356 additions and 33 deletions
+171 -25
View File
@@ -3,9 +3,10 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::State;
use axum::extract::{Query, State};
use axum::response::Html;
use doctate_common::oneliners::OnelinerState;
use serde::Deserialize;
use tracing::{info, warn};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
@@ -73,6 +74,15 @@ struct UserCaseView {
/// Requires: not currently analyzing, all non-failed recordings
/// transcribed, and an LLM is configured.
can_analyze: bool,
/// True iff the case carries a `.closed` marker. Drives the muted
/// styling, the Close/Reopen button swap, and the purge countdown.
is_closed: bool,
/// Remaining days before auto-purge. `Some(n)` only when the case
/// is closed AND `auto_delete_days > 0`; `None` otherwise (so the
/// template can render "wird in N Tagen entfernt" vs. plain
/// "geschlossen"). Clamped at 0 — cases eligible for purge this
/// sweep render as "0" briefly.
days_until_purge: Option<u32>,
}
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
@@ -154,6 +164,30 @@ struct MyCasesTemplate {
total: usize,
/// True iff the session user is an admin — toggles full case_id display.
is_admin: bool,
/// Reflects the `?show_closed=1` query param. Controls the toggle
/// link state and makes the bulk-purge button + per-case reopen
/// buttons visible.
show_closed: bool,
/// True iff at least one closed case is currently visible. Needed
/// to gate the bulk-purge bar so it does not appear on an
/// all-open list rendered with `show_closed=1`.
any_closed: bool,
}
/// Query params for GET /web/cases and /web/cases/{case_id}.
#[derive(Debug, Default, Deserialize)]
pub struct CaseListQuery {
/// `?show_closed=1` expands the list to include closed cases and
/// swaps per-case close buttons with reopen buttons. Any other
/// value (missing, "0", empty) keeps the default "open only" view.
#[serde(default)]
show_closed: Option<String>,
}
impl CaseListQuery {
fn include_closed(&self) -> bool {
matches!(self.show_closed.as_deref(), Some("1"))
}
}
#[derive(Template)]
@@ -179,6 +213,11 @@ struct CasePageTemplate {
analyzing: bool,
has_document: bool,
is_admin: bool,
/// True when the case carries a `.closed` marker. Toggles the
/// header action button between close and reopen, and appends
/// a "?show_closed=1" to the form action so a freshly reopened
/// case still lands on the detail page.
is_closed: bool,
}
#[derive(Template)]
@@ -293,6 +332,7 @@ pub async fn handle_my_cases(
State(events_tx): State<EventSender>,
State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>,
Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline
@@ -316,14 +356,26 @@ pub async fn handle_my_cases(
// reusing the user's retention policy from users.toml. Runs before
// the listing scan so any auto-close/auto-purge actions are
// reflected in the same response.
if let Some(u) = config.users.iter().find(|u| u.slug == user.slug) {
crate::retention::sweep_user_retention(&user_root, &user.slug, &u.retention, &events_tx)
.await;
}
let retention = config
.users
.iter()
.find(|u| u.slug == user.slug)
.map(|u| u.retention.clone())
.unwrap_or_default();
crate::retention::sweep_user_retention(&user_root, &user.slug, &retention, &events_tx).await;
let show_closed = query.include_closed();
let busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await;
let cases = scan_user_cases(
&config,
&user.slug,
busy,
show_closed,
retention.auto_delete_days,
)
.await;
let total = cases.len();
let any_closed = cases.iter().any(|c| c.is_closed);
let groups = group_by_utc_date(cases);
let is_admin = user.is_admin();
MyCasesTemplate {
@@ -331,6 +383,8 @@ pub async fn handle_my_cases(
groups,
total,
is_admin,
show_closed,
any_closed,
}
.render()
.map(Html)
@@ -343,6 +397,7 @@ pub async fn handle_my_cases(
/// shows status (analyzing / placeholder for empty / recordings-summary).
/// Action buttons (Analyze/Reanalyze/Reset/Delete) live here, not on the
/// recordings sub-page.
#[allow(clippy::too_many_arguments)]
pub async fn handle_case_page(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
@@ -351,6 +406,7 @@ pub async fn handle_case_page(
State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>,
CaseIdPath(case_id): CaseIdPath,
Query(query): Query<CaseListQuery>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline
@@ -364,13 +420,24 @@ pub async fn handle_case_page(
)
.await;
let case_dir = locate_case_or_404(
&user_root,
&case_id,
&user.slug,
"case page (possible IDOR probe)",
)
.await?;
let show_closed = query.include_closed();
let case_dir = if show_closed {
locate_closed_case_or_404(
&user_root,
&case_id,
&user.slug,
"case page (show_closed=1)",
)
.await?
} else {
locate_case_or_404(
&user_root,
&case_id,
&user.slug,
"case page (possible IDOR probe)",
)
.await?
};
// Auto-analysis trigger for deep-link navigation: same evaluation as
// the list view. Document render below still wins the race if the
// worker happens to finish synchronously.
@@ -402,6 +469,8 @@ pub async fn handle_case_page(
.filter(|s| !s.is_empty());
let time_hms_utc = most_recent.map(extract_hhmm_utc).unwrap_or_default();
let is_closed = crate::paths::is_closed(&case_dir).await;
CasePageTemplate {
case_id: case_id_str,
case_id_short,
@@ -416,6 +485,7 @@ pub async fn handle_case_page(
analyzing: flags.analyzing,
has_document: flags.has_document,
is_admin,
is_closed,
}
.render()
.map(Html)
@@ -518,14 +588,47 @@ pub(crate) async fn locate_case_or_404(
}
}
/// Like `locate_case_or_404` but accepts closed cases. Only the detail
/// view uses this, and only when the user navigated there with
/// `?show_closed=1`. A non-existent directory still 404s as expected.
pub(crate) async fn locate_closed_case_or_404(
user_root: &Path,
case_id: &CaseId,
user_slug: &str,
context: &str,
) -> Result<std::path::PathBuf, AppError> {
let p = user_root.join(case_id.to_string());
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
warn!(
slug = %user_slug,
case_id = %case_id,
context = %context,
"case not found (show_closed)",
);
return Err(AppError::NotFound("Case not found".into()));
}
Ok(p)
}
/// 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. Closed cases (with
/// `.closed` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<UserCaseView> {
/// timestamps are ISO-8601), newest first.
///
/// `include_closed` flips the default "closed cases are hidden"
/// behaviour for the show-closed view. `auto_delete_days` is used to
/// compute the per-case `days_until_purge` countdown; pass 0 to
/// disable the countdown (maps to `None` on the view).
async fn scan_user_cases(
config: &Config,
slug: &str,
worker_busy: bool,
include_closed: bool,
auto_delete_days: u32,
) -> Vec<UserCaseView> {
let user_root = config.data_path.join(slug);
let llm_configured = config.llm_configured();
let mut cases = Vec::new();
let now = OffsetDateTime::now_utc();
let mut entries = match tokio::fs::read_dir(&user_root).await {
Ok(r) => r,
@@ -533,11 +636,18 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
};
while let Ok(Some(entry)) = entries.next_entry().await {
let Some((case_id, case_path)) = filter_valid_case_dir(entry).await else {
let Some((case_id, case_path)) = filter_valid_case_dir(entry, include_closed).await else {
continue;
};
if let Some(view) =
compute_case_view(case_id, &case_path, llm_configured, worker_busy).await
if let Some(view) = compute_case_view(
case_id,
&case_path,
llm_configured,
worker_busy,
auto_delete_days,
now,
)
.await
{
cases.push(view);
}
@@ -547,11 +657,14 @@ async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec<
cases
}
/// Accept an entry only if it is a non-deleted directory whose name is
/// a valid UUID. Returns the (case_id-string, absolute path) tuple so
/// the caller does not need to recompute either. Non-UUID names are
/// logged at WARN level; other rejections are silent (common case).
async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, std::path::PathBuf)> {
/// Accept an entry only if it is a directory whose name is a valid UUID.
/// Closed cases are rejected unless `include_closed` is set; then the
/// caller (the show-closed listing) will downstream-render them with
/// muted styling and a badge.
async fn filter_valid_case_dir(
entry: tokio::fs::DirEntry,
include_closed: bool,
) -> Option<(String, std::path::PathBuf)> {
let case_id = entry.file_name().into_string().ok()?;
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
@@ -561,7 +674,7 @@ async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, st
if !case_path.is_dir() {
return None;
}
if crate::paths::is_closed(&case_path).await {
if !include_closed && crate::paths::is_closed(&case_path).await {
return None;
}
Some((case_id, case_path))
@@ -574,6 +687,8 @@ async fn compute_case_view(
case_path: &Path,
llm_configured: bool,
worker_busy: bool,
auto_delete_days: u32,
now: OffsetDateTime,
) -> Option<UserCaseView> {
let recordings = scan_recordings(case_path).await;
if recordings.is_empty() {
@@ -598,6 +713,8 @@ async fn compute_case_view(
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_path).await;
let can_analyze = !analyzing && all_transcribed && llm_configured;
let (is_closed, days_until_purge) = closed_state(case_path, auto_delete_days, now).await;
let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
Some(UserCaseView {
@@ -610,9 +727,38 @@ async fn compute_case_view(
analyzing,
has_document,
can_analyze,
is_closed,
days_until_purge,
})
}
/// Read the close marker (if any) and compute the purge countdown.
/// Returns `(is_closed, days_until_purge)`. `days_until_purge` is
/// `None` whenever the purge sweep is disabled (`auto_delete_days = 0`)
/// or the case is still open — the template then suppresses the
/// countdown and falls back to the bare "geschlossen" badge.
async fn closed_state(
case_path: &Path,
auto_delete_days: u32,
now: OffsetDateTime,
) -> (bool, Option<u32>) {
let Some(marker) = crate::paths::read_close_marker(case_path).await else {
// No marker (open case) OR malformed marker. Malformed should
// have been caught by `is_closed` upstream; we treat it as open
// here to avoid a bogus countdown on a half-written file.
let open_but_marker_present = crate::paths::is_closed(case_path).await;
return (open_but_marker_present, None);
};
if auto_delete_days == 0 {
return (true, None);
}
let age_days = OffsetDateTime::parse(&marker.closed_at, &Rfc3339)
.map(|t| (now - t).whole_days().max(0))
.unwrap_or(0);
let remaining = (auto_delete_days as i64).saturating_sub(age_days).max(0);
(true, Some(remaining as u32))
}
/// Extract only the text from a `Ready` state; other states (Empty,
/// Error, missing file) collapse to `None`. Used by the recordings
/// sub-page, which only needs a plain title string as heading.