From 23e828df45790449e106cfac613cbc93f18e1e60 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 21 Apr 2026 10:58:00 +0200 Subject: [PATCH] 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. --- server/src/routes/user_web.rs | 196 ++++++++++++++++++++++++++++---- server/templates/case_page.html | 32 ++++++ server/templates/my_cases.html | 42 +++++-- server/tests/analyze_test.rs | 119 +++++++++++++++++++ 4 files changed, 356 insertions(+), 33 deletions(-) diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 441f7ae..52f6089 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -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, } /// 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, +} + +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, State(http_client): State, State(vocab): State>, + Query(query): Query, ) -> Result, 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>, @@ -351,6 +406,7 @@ pub async fn handle_case_page( State(http_client): State, State(vocab): State>, CaseIdPath(case_id): CaseIdPath, + Query(query): Query, ) -> Result, 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 { + 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 { +/// 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 { 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 { 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) { + 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. diff --git a/server/templates/case_page.html b/server/templates/case_page.html index 5faeb6e..eb1ba6e 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -261,6 +261,37 @@ >{% else %}offen{% endif %} + {% if is_closed %} +
+ +
+ {% else %}
+ {% endif %} {% match recorded_at_iso %} {% when Some with (iso) %}

diff --git a/server/templates/my_cases.html b/server/templates/my_cases.html index 4c3692c..35d8628 100644 --- a/server/templates/my_cases.html +++ b/server/templates/my_cases.html @@ -36,6 +36,14 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; } .case-row .actions button { font-size: 0.85em; padding: 0.35em 0.8em; } .case-row .actions .delete-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; } .case-row .actions .delete-btn:hover { color: #e24a4a; background: #fff0f0; } +.case-row .actions .reopen-btn { padding: 0.3em; background: transparent; border: none; color: #888; cursor: pointer; display: inline-flex; align-items: center; line-height: 0; border-radius: 4px; transition: color 0.15s, background 0.15s; } +.case-row .actions .reopen-btn:hover { color: #4a90e2; background: #eef4fb; } +.case-row.closed { opacity: 0.65; background: #fafafa; } +.case-row.closed .body a { color: #777; } +.case-row .closed-badge { display: inline-block; margin-left: 0.5em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; background: #ddd; color: #444; } + +.closed-toggle { display: block; margin: 0.5em 0 1em; font-size: 0.9em; color: #4a90e2; text-decoration: none; } +.closed-toggle:hover { text-decoration: underline; } .label { display: inline-block; margin-left: 0.6em; padding: 0.05em 0.5em; border-radius: 999px; font-size: 0.75em; font-weight: bold; vertical-align: middle; } .label.analyzing { background: #eee; color: #555; } @@ -43,8 +51,9 @@ section h2 .count { font-weight: normal; font-size: 0.85em; color: #888; } .bulk-bar { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; display: flex; gap: 0.6em; flex-wrap: wrap; align-items: center; } .bulk-bar button { font-size: 0.9em; padding: 0.5em 1em; } -.bulk-bar .undo { margin-left: auto; } -.bulk-bar .undo button { background: #fff3cd; border: 1px solid #d39e00; color: #5a4500; } +.bulk-bar.purge { background: #fff0f0; border: 1px solid #e24a4a; } +.bulk-bar.purge button { background: #e24a4a; color: white; border: none; } +.bulk-bar.purge button:hover { background: #c43030; } .header-right { display: flex; align-items: center; gap: 0.8em; } .admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; } @@ -67,8 +76,14 @@ try { +{% if show_closed %} +← Nur offene Fälle anzeigen +{% else %} +Geschlossene Fälle einblenden +{% endif %} + {% if total == 0 %} -

Keine Fälle.

+

{% if show_closed %}Keine offenen oder geschlossenen Fälle.{% else %}Keine Fälle.{% endif %}

{% else %}
{% for g in groups %} @@ -76,19 +91,23 @@ try {

{{ g.label }}{% if g.cases.len() == 1 %}1 Fall{% else %}{{ g.cases.len() }} Fälle{% endif %}

    {% for case in g.cases %} -
  • -
    +
  • +
    {% if !case.is_closed %}{% endif %}
    -{% if is_admin %} +{% if !case.is_closed && is_admin %} {% endif %} +{% if case.is_closed %} + +{% else %} +{% endif %}
  • {% endfor %} @@ -103,6 +122,13 @@ try { +{% if show_closed && any_closed %} +
    + +Geschlossene Fälle: + +
    +{% endif %} {% endif %}