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:
+171
-25
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user