Files
doctate/server/src/routes/user_web.rs
T
Brummel 661ea6215e Add analysis preview to case list
Introduce a `preview_lines` setting in `users.toml` to control the
number of visible lines for the analysis preview in the case list. This
feature extracts the first paragraph of the `document.md` file, strips
markdown formatting, and displays it, capped by the configured
`preview_lines`. The actual line clamping is handled client-side via CSS
`line-clamp`.
2026-04-21 17:25:14 +02:00

974 lines
35 KiB
Rust

use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::{Query, State};
use axum::response::Html;
use doctate_common::TranscriptState;
use doctate_common::oneliners::OnelinerState;
use serde::Deserialize;
use tracing::{info, warn};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
use crate::analyze::{
ANALYSIS_INPUT_FILE, DOCUMENT_FILE, auto_trigger, recovery as analyze_recovery,
};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::{CaseId, CaseIdPath};
use crate::config::Config;
use crate::error::AppError;
use crate::events::EventSender;
use crate::gazetteer::Gazetteer;
use crate::paths;
use crate::routes::case_actions::read_document;
use crate::routes::web::{RecordingView, scan_recordings};
use crate::transcribe::recovery as transcribe_recovery;
use crate::{BusyGuard, PipelineState};
/// UI-layer oneliner status for the case list. Combines the persisted
/// [`OnelinerState`] with derived transit states that only make sense
/// in the list context (transcription still pending, or no state file
/// despite completed transcription).
///
/// Kept local to the server — not part of the public API DTO, which
/// exposes the raw [`OnelinerState`] and lets each client decide how
/// to render transit states.
enum OnelinerDisplay {
/// LLM produced a usable title.
Ready(String),
/// Stable "no title to expect" — either the LLM returned nothing
/// (no medical content in transcript) or every recording failed so
/// nothing can ever generate a title. UI treats both identically;
/// the reason is uninteresting to the doctor in the list view.
Empty,
/// LLM call failed with transcripts available.
Error,
/// Transcription still running — a title cannot exist yet.
Pending,
/// Transcripts are done (or about to land) but the LLM hasn't
/// written a state yet, OR a previous state is obsolete because new
/// recordings arrived. The worker is generating right now or will
/// generate as soon as the last transcript is in. Covers the
/// reset-just-clicked case and the post-restart recovery window.
Generating,
}
struct UserCaseView {
case_id: String,
most_recent: String,
/// HH:MM of the most recent recording in UTC — no-JS fallback.
/// Browser script replaces the displayed text with the browser-local
/// equivalent via the parallel `recorded_at_iso` field.
time_hms_utc: String,
/// Full RFC3339 UTC timestamp of the most recent recording. Fed into
/// the HTML `<time datetime="...">` attribute so browser JS can
/// render it in the doctor's actual timezone (which may differ from
/// the server's, e.g. server on Bahamas, doctor in Germany).
recorded_at_iso: String,
oneliner: OnelinerDisplay,
recordings_count: usize,
analyzing: bool,
has_document: bool,
/// True iff at least one recording is `.m4a.failed` — a *permanent*
/// transcribe failure (ffmpeg remux or Whisper 4xx). Transient Whisper
/// errors never set this flag because they leave the file as plain
/// `.m4a` for the page-load heal to retry. Drives the verdrängende
/// `fehler`-Badge over `offen`/`ausgewertet`.
has_failed_recording: 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,
/// 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>,
/// First paragraph of `document.md` as plain text (markdown
/// formatting stripped). `None` when the case has no document yet.
/// The browser CSS-clamps this to the user's configured number of
/// lines; no server-side line counting happens.
analysis_preview: Option<String>,
}
/// Parse a recording filename `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]` as UTC
/// and return `(Date, Rfc3339String)`. None on malformed input.
fn utc_date_and_iso_of(filename: &str) -> Option<(Date, String)> {
let s = filename.get(..20)?;
if s.as_bytes().get(19) != Some(&b'Z') {
return None;
}
// Filename uses `-` between H/M/S; Rfc3339 wants `:`. Patch two bytes.
let mut buf = [0u8; 20];
buf.copy_from_slice(s.as_bytes());
buf[13] = b':';
buf[16] = b':';
let fixed = std::str::from_utf8(&buf).ok()?;
let dt = OffsetDateTime::parse(fixed, &Rfc3339).ok()?;
Some((dt.date(), fixed.to_owned()))
}
/// Group cases (already sorted newest-first) into UTC-date buckets. The
/// label is the raw ISO date string (e.g. `2026-04-18`); browser JS
/// re-labels the current/previous UTC date to "Heute"/"Gestern". Note:
/// grouping is by UTC day, which may drift from the doctor's local-day
/// perception around midnight — acceptable tradeoff; full Browser-TZ
/// grouping would require a larger client-side restructure.
fn group_by_utc_date(cases: Vec<UserCaseView>) -> Vec<DateGroup> {
let today = OffsetDateTime::now_utc().date();
let mut groups: Vec<DateGroup> = Vec::new();
for case in cases {
let d = utc_date_and_iso_of(&case.most_recent)
.map(|(d, _)| d)
.unwrap_or(today);
let label = d.to_string();
match groups.last_mut() {
Some(g) if g.label == label => g.cases.push(case),
_ => groups.push(DateGroup {
label,
cases: vec![case],
}),
}
}
groups
}
/// Extract `HH:MM` from a recording filename of the form
/// `YYYY-MM-DDTHH-MM-SSZ.m4a[.failed]`. Returns `""` on any mismatch.
/// Used only as a no-JS fallback — the canonical value rendered in the
/// browser comes from the `<time datetime>`-based script.
fn extract_hhmm_utc(filename: &str) -> String {
let (Some(h), Some(m)) = (filename.get(11..13), filename.get(14..16)) else {
return String::new();
};
if h.bytes().all(|b| b.is_ascii_digit()) && m.bytes().all(|b| b.is_ascii_digit()) {
format!("{h}:{m}")
} else {
String::new()
}
}
/// Build an RFC3339 UTC timestamp string from a recording filename.
/// Returns empty string on malformed input.
fn recorded_at_iso_of(filename: &str) -> String {
utc_date_and_iso_of(filename)
.map(|(_, iso)| iso)
.unwrap_or_default()
}
/// Extract the first paragraph of a `document.md` body as plain text.
///
/// A "paragraph" here is the first non-empty block separated by a blank
/// line (`\n\n`). Markdown decoration characters (`#`, `*`, `_`, `` ` ``,
/// `=`, `>`) are stripped — the preview is meant to appear in a cramped
/// list row where bold/italic/heading styling would only add noise.
/// Returns `None` for input that ends up blank after stripping.
///
/// The visible line count is NOT enforced here: that is browser-side via
/// CSS `line-clamp`. Server logic stays viewport-agnostic.
fn preview_from_markdown(md: &str) -> Option<String> {
let first = md.split("\n\n").map(str::trim).find(|p| !p.is_empty())?;
let plain: String = first
.chars()
.filter(|c| !matches!(c, '#' | '*' | '_' | '`' | '=' | '>'))
.collect();
let trimmed = plain.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
struct DateGroup {
/// "Heute", "Gestern", or ISO date "YYYY-MM-DD".
label: String,
cases: Vec<UserCaseView>,
}
#[derive(Template)]
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
slug: String,
groups: Vec<DateGroup>,
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,
/// Per-user cap on visible analysis-preview lines. Rendered as a
/// CSS `--preview-lines` custom property on `<html>`; the browser
/// applies it via `line-clamp`. Coming from `users.toml`.
preview_lines: u32,
}
/// 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)]
#[template(path = "case_page.html")]
struct CasePageTemplate {
case_id: String,
case_id_short: String,
oneliner: OnelinerDisplay,
/// RFC3339 UTC timestamp of the most recent recording. `None` iff the
/// case has no recordings at all. Drives the datetime header under the
/// title; browser JS formats it to "Heute 11:34" / "13.12.2022 11:34".
recorded_at_iso: Option<String>,
/// UTC `HH:MM` as a no-JS fallback inside the `<time>` element.
time_hms_utc: String,
/// Rendered document HTML (already passed through `md_to_html`). `Some`
/// iff `document.md` exists at render time — template prioritises this
/// over status/analyzing placeholders.
document_html: Option<String>,
recordings_count: usize,
transcribed_count: usize,
can_analyze: bool,
llm_missing: bool,
analyzing: bool,
has_document: bool,
/// True iff at least one `.m4a.failed` recording exists. Drives the
/// verdrängende `fehler`-Badge in the case header; see the same
/// field on `UserCaseView` for semantics.
has_failed_recording: 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)]
#[template(path = "case_recordings.html")]
struct CaseRecordingsTemplate {
slug: String,
case_id: String,
case_id_short: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
transcribe_busy: bool,
is_admin: bool,
}
/// 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_analyze: bool,
llm_missing: bool,
}
async fn compute_flags(
case_dir: &Path,
recordings: &[RecordingView],
llm_configured: bool,
worker_busy: bool,
) -> CaseFlags {
let has_document = any_document_exists(case_dir).await;
// Honest status: an input file alone does not mean a job is in flight.
// The worker may be idle and the file an orphan from a crash. Page-level
// self-heal (see `heal_orphans_if_idle`) re-enqueues those.
let analyzing = !has_document && worker_busy && any_analysis_input_exists(case_dir).await;
let non_failed: Vec<&RecordingView> = recordings.iter().filter(|r| !r.failed).collect();
let all_transcribed =
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.has_file());
let analyzable = !analyzing && all_transcribed;
CaseFlags {
has_document,
analyzing,
can_analyze: analyzable && llm_configured,
llm_missing: analyzable && !llm_configured,
}
}
/// True iff `analysis_input.json` exists. The worker removes it after a
/// successful document write, so existence implies a pending or in-flight job.
pub(crate) async fn any_analysis_input_exists(case_dir: &Path) -> bool {
tokio::fs::try_exists(case_dir.join(ANALYSIS_INPUT_FILE))
.await
.unwrap_or(false)
}
/// True iff `document.md` exists.
pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
tokio::fs::try_exists(case_dir.join(DOCUMENT_FILE))
.await
.unwrap_or(false)
}
/// Self-heal: if a worker is idle but orphans exist on disk for this user,
/// re-enqueue them. Page-load is the trigger; no cron, no periodic task.
/// Runs for both pipelines so a page-load on either view cleans both.
///
/// Also opportunistically regenerates missing oneliners for the user — a
/// recording-delete leaves `oneliner.json` gone, and without this heal
/// call the stale state would only recover on server restart.
impl PipelineState {
#[allow(clippy::too_many_arguments)]
pub async fn heal_orphans_if_idle(
&self,
user_root: &Path,
slug: &str,
http_client: &reqwest::Client,
config: &Arc<Config>,
vocab: &Arc<Gazetteer>,
events_tx: &EventSender,
) {
if !self.analyze_busy.0.load(Ordering::Acquire) {
analyze_recovery::enqueue_pending_for_user(user_root, &self.analyze_tx).await;
}
if !self.transcribe_busy.0.load(Ordering::Acquire) {
transcribe_recovery::enqueue_pending_for_user(user_root, slug, &self.transcribe_tx)
.await;
// Oneliner self-heal: if the transcribe worker is busy, it
// will write a fresh oneliner at batch-end anyway — skip to
// avoid a redundant LLM call. Otherwise, fire the regen in a
// detached task so the request handler returns immediately;
// SSE `OnelinerUpdated` events deliver results to the browser.
// The CAS on `oneliner_heal_busy` guards against concurrent
// page-loads spawning their own parallel regen loops against
// the same cases.
if self
.oneliner_heal_busy
.0
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
let user_root = user_root.to_path_buf();
let slug_owned = slug.to_owned();
let http_client = http_client.clone();
let config = Arc::clone(config);
let vocab = Arc::clone(vocab);
let events_tx = events_tx.clone();
let busy = Arc::clone(&self.oneliner_heal_busy.0);
info!(user = %slug, "oneliner heal spawned");
tokio::spawn(async move {
// `BusyGuard::Drop` resets the flag to `false` — even
// on panic inside the regen loop. The redundant
// `store(true)` inside `BusyGuard::new` is harmless;
// the CAS above already owns the flag transition.
let _guard = BusyGuard::new(busy);
transcribe_recovery::regenerate_missing_oneliners_for_user(
&user_root,
&slug_owned,
&http_client,
&config,
&vocab,
&events_tx,
)
.await;
});
}
}
}
}
pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(pipeline): State<PipelineState>,
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
.heal_orphans_if_idle(
&user_root,
&user.slug,
&http_client,
&config,
&vocab,
&events_tx,
)
.await;
// Auto-analysis: hand every eligible case to the worker before we
// render. The common case is "nothing to do" and costs a handful of
// stat-calls per case; actual enqueues happen only when pre-conditions
// flip (new transcripts in, stale document, ...).
auto_trigger::try_enqueue_all_for_user(&user_root, &pipeline.analyze_tx, &config, &events_tx)
.await;
// Lazy retention sweep: at most one disk scan per /web/cases visit,
// 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.
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,
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();
let preview_lines = config
.users
.iter()
.find(|u| u.slug == user.slug)
.map(|u| u.preview_lines)
.unwrap_or(2);
MyCasesTemplate {
slug: user.slug,
groups,
total,
is_admin,
show_closed,
any_closed,
preview_lines,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// GET /web/cases/{case_id}
///
/// Canonical case page. Renders the document inline if present; otherwise
/// 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>>,
State(pipeline): State<PipelineState>,
State(events_tx): State<EventSender>,
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
.heal_orphans_if_idle(
&user_root,
&user.slug,
&http_client,
&config,
&vocab,
&events_tx,
)
.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.
auto_trigger::try_enqueue(&case_dir, &pipeline.analyze_tx, &config, &events_tx).await;
let case_id_str = case_id.to_string();
info!(slug = %user.slug, case_id = %case_id, "case page viewed");
// Read document first; if it's on disk we display it regardless of what
// the `analyzing` flag would otherwise say. Resolves the narrow race
// where the worker finishes between flag check and template render.
let document_html = read_document(&case_dir)
.await
.map(|md| crate::analyze::render::md_to_html(&md));
let recordings = scan_recordings(&case_dir).await;
let oneliner = compute_oneliner_display(&case_dir, &recordings).await;
let a_busy = pipeline.analyze_busy.0.load(Ordering::Acquire);
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
let recordings_count = recordings.len();
let transcribed_count = recordings
.iter()
.filter(|r| r.transcript.has_file())
.count();
let has_failed_recording = recordings.iter().any(|r| r.failed);
let case_id_short = case_id_str.chars().take(8).collect();
let is_admin = user.is_admin();
let most_recent = recordings.last().map(|r| r.filename.as_str());
let recorded_at_iso = most_recent
.map(recorded_at_iso_of)
.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,
oneliner,
recorded_at_iso,
time_hms_utc,
document_html,
recordings_count,
transcribed_count,
can_analyze: flags.can_analyze,
llm_missing: flags.llm_missing,
analyzing: flags.analyzing,
has_document: flags.has_document,
has_failed_recording,
is_admin,
is_closed,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// GET /web/cases/{case_id}/recordings
///
/// Read-only sub-page showing all `.m4a` + transcript pairs for the case.
/// Useful for power-users/admins; not part of the day-to-day workflow.
pub async fn handle_case_recordings(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(pipeline): State<PipelineState>,
State(events_tx): State<EventSender>,
State(http_client): State<reqwest::Client>,
State(vocab): State<Arc<Gazetteer>>,
CaseIdPath(case_id): CaseIdPath,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline
.heal_orphans_if_idle(
&user_root,
&user.slug,
&http_client,
&config,
&vocab,
&events_tx,
)
.await;
let case_dir = locate_case_or_404(
&user_root,
&case_id,
&user.slug,
"case recordings (possible IDOR probe)",
)
.await?;
let case_id_str = case_id.to_string();
info!(slug = %user.slug, case_id = %case_id, "case recordings viewed");
let recordings = scan_recordings(&case_dir).await;
let oneliner = ready_text(&case_dir).await;
let t_busy = pipeline.transcribe_busy.0.load(Ordering::Acquire);
let case_id_short = case_id_str.chars().take(8).collect();
let is_admin = user.is_admin();
CaseRecordingsTemplate {
slug: user.slug,
case_id: case_id_str,
case_id_short,
oneliner,
recordings,
transcribe_busy: t_busy,
is_admin,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
/// if the directory does not exist OR is closed (`.closed` marker
/// present). Both are treated as 404 / IDOR probe by callers.
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<std::path::PathBuf> {
let p = user_root.join(case_id);
if !tokio::fs::try_exists(&p).await.unwrap_or(false) {
return None;
}
if crate::paths::is_closed(&p).await {
return None;
}
Some(p)
}
/// Resolve a case directory for a handler, mapping any miss to
/// `AppError::NotFound("Case not found")` — matches the legacy 404 body
/// byte-for-byte. The `context` tag distinguishes log lines from the
/// different handlers (analyze, delete, reset, ...). Callers that need
/// a different not-found policy (silent skip in bulk ops) should keep
/// using `locate_case` directly.
pub(crate) async fn locate_case_or_404(
user_root: &Path,
case_id: &CaseId,
user_slug: &str,
context: &str,
) -> Result<std::path::PathBuf, AppError> {
match locate_case(user_root, &case_id.to_string()).await {
Some(p) => Ok(p),
None => {
warn!(
slug = %user_slug,
case_id = %case_id,
context = %context,
"case not found",
);
Err(AppError::NotFound("Case not found".into()))
}
}
}
/// 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.
///
/// `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,
Err(_) => return cases,
};
while let Ok(Some(entry)) = entries.next_entry().await {
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,
auto_delete_days,
now,
)
.await
{
cases.push(view);
}
}
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}
/// 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");
return None;
}
let case_path = entry.path();
if !case_path.is_dir() {
return None;
}
if !include_closed && crate::paths::is_closed(&case_path).await {
return None;
}
Some((case_id, case_path))
}
/// Build a `UserCaseView` for a single case directory. Returns `None`
/// for empty cases (no recordings yet) so the caller can skip them.
async fn compute_case_view(
case_id: String,
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() {
return None;
}
let most_recent = recordings
.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 has_failed_recording = recordings.iter().any(|r| r.failed);
let all_transcribed = non_failed_count > 0
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.has_file());
let oneliner = compute_oneliner_display(case_path, &recordings).await;
let has_document = any_document_exists(case_path).await;
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;
// One extra read per case with a document. Documents are in the KB
// range — negligible next to the existing scan_recordings I/O — and
// we skip the read entirely when the stat above said no document.
let analysis_preview = if has_document {
read_document(case_path)
.await
.and_then(|md| preview_from_markdown(&md))
} else {
None
};
let time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
Some(UserCaseView {
case_id,
most_recent,
time_hms_utc,
recorded_at_iso,
oneliner,
recordings_count,
analyzing,
has_document,
has_failed_recording,
can_analyze,
is_closed,
days_until_purge,
analysis_preview,
})
}
/// 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.
async fn ready_text(case_dir: &Path) -> Option<String> {
match paths::read_oneliner_state(case_dir).await.0 {
Some(OnelinerState::Ready { text, .. }) => Some(text),
_ => None,
}
}
/// Compute the 5-state UI display for the oneliner. Identical logic to
/// what `compute_case_view` uses for the case list — extracted so the
/// case page can render the same states instead of a plain "Fall"
/// fallback.
async fn compute_oneliner_display(
case_dir: &Path,
recordings: &[RecordingView],
) -> OnelinerDisplay {
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.has_file());
let (state, _) = paths::read_oneliner_state(case_dir).await;
if non_failed_count == 0 {
// All recordings failed — stable end state, no LLM will ever run.
// Missing state collapses to Empty ("unbenannt"): no title is
// expected, regardless of why.
match state {
Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text),
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
Some(OnelinerState::Empty { .. }) | None => OnelinerDisplay::Empty,
}
} else if all_transcribed {
// Transcripts done — stable state, except for the brief window
// between transcript-write and state-write where the worker is
// still producing its result.
match state {
Some(OnelinerState::Ready { text, .. }) => OnelinerDisplay::Ready(text),
Some(OnelinerState::Empty { .. }) => OnelinerDisplay::Empty,
Some(OnelinerState::Error { .. }) => OnelinerDisplay::Error,
None => OnelinerDisplay::Generating,
}
} else {
// Transcription still pending. Any persisted state is obsolete:
// the worker will regenerate once the last transcript lands.
match state {
None => OnelinerDisplay::Pending,
Some(_) => OnelinerDisplay::Generating,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_from_markdown_takes_first_paragraph() {
let md = "Erster Absatz mit Text.\n\nZweiter Absatz, irrelevant.";
assert_eq!(
preview_from_markdown(md).as_deref(),
Some("Erster Absatz mit Text.")
);
}
#[test]
fn preview_from_markdown_strips_formatting_chars() {
let md = "# Überschrift\n\n**Patient** klagt über _Knie_ mit ==Schmerz==.";
// Both the heading and the inline formatting survive only as plain text.
assert_eq!(preview_from_markdown(md).as_deref(), Some("Überschrift"));
}
#[test]
fn preview_from_markdown_skips_leading_blank_paragraphs() {
let md = "\n\n\n\nErster echter Absatz.";
assert_eq!(
preview_from_markdown(md).as_deref(),
Some("Erster echter Absatz.")
);
}
#[test]
fn preview_from_markdown_returns_none_for_blank_input() {
assert_eq!(preview_from_markdown(""), None);
assert_eq!(preview_from_markdown(" \n\n "), None);
}
#[test]
fn preview_from_markdown_returns_none_when_only_formatting() {
// An all-formatting "paragraph" (no actual letters/numbers after
// strip) should collapse to None rather than an empty preview row.
assert_eq!(preview_from_markdown("=== ## **"), None);
}
#[test]
fn preview_from_markdown_strips_inline_formatting_but_keeps_words() {
let md = "**Fett** und _kursiv_ und `code` in einer Zeile.";
assert_eq!(
preview_from_markdown(md).as_deref(),
Some("Fett und kursiv und code in einer Zeile.")
);
}
#[test]
fn preview_from_markdown_drops_blockquote_marker() {
let md = "> Patient berichtet über Schmerzen.";
assert_eq!(
preview_from_markdown(md).as_deref(),
Some("Patient berichtet über Schmerzen.")
);
}
}