Files
doctate/server/src/routes/user_web.rs
T
Brummel cdfa5ae90e Refactor timestamp handling for activity sorting
Introduce `last_recording_at` to `OnelinerEntry` and use it as the
primary sort key for cases. This ensures that cases with recent
dictation activity are prioritized, even if their initial creation date
is older.

The logic for determining a case's activity has been updated to consider
the most recent `.m4a` file's modification time (`last_recording_at`),
falling back to `updated_at` or `created_at` if necessary.

This change also refactors the timestamp formatting and handling within
the web interface to correctly display and sort cases based on their
actual last activity time, improving user experience and data relevance.
The `now_rfc3339` function is updated to strip sub-second precision for
consistent filename generation.
2026-04-18 11:33:35 +02:00

414 lines
14 KiB
Rust

use std::path::Path;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use askama::Template;
use axum::extract::{Path as AxumPath, State};
use axum::response::Html;
use tracing::{info, warn};
use time::{format_description::well_known::Rfc3339, Date, OffsetDateTime};
use crate::analyze::{recovery as analyze_recovery, AnalyzeSender, ANALYSIS_INPUT_FILE, DOCUMENT_FILE};
use crate::auth::AuthenticatedWebUser;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{scan_recordings, RecordingView};
use crate::transcribe::{recovery as transcribe_recovery, TranscribeSender};
use crate::{AnalyzeBusy, TranscribeBusy};
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: Option<String>,
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,
}
/// 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()
}
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,
/// Number of cases that "Undo last delete" would restore. 0 hides the
/// undo button entirely.
undo_count: usize,
/// True iff the session user is an admin — toggles full case_id display.
is_admin: bool,
}
#[derive(Template)]
#[template(path = "case_detail.html")]
struct CaseDetailTemplate {
slug: String,
case_id: String,
case_id_short: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
can_analyze: bool,
llm_missing: bool,
analyzing: bool,
has_document: bool,
/// True iff the transcribe worker is currently running. Gate for the
/// per-recording "Transkription läuft…" label — suppresses the lie when
/// a transcript is missing but no worker is active.
transcribe_busy: bool,
/// True iff the session user is an admin — toggles admin-only controls
/// (currently the Reset button).
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.is_some());
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.
async fn heal_orphans_if_idle(
user_root: &Path,
slug: &str,
analyze_busy: &AnalyzeBusy,
analyze_tx: &AnalyzeSender,
transcribe_busy: &TranscribeBusy,
transcribe_tx: &TranscribeSender,
) {
if !analyze_busy.0.load(Ordering::Acquire) {
analyze_recovery::enqueue_pending_for_user(user_root, analyze_tx).await;
}
if !transcribe_busy.0.load(Ordering::Acquire) {
transcribe_recovery::enqueue_pending_for_user(user_root, slug, transcribe_tx).await;
}
}
pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_busy): State<AnalyzeBusy>,
State(analyze_tx): State<AnalyzeSender>,
State(transcribe_busy): State<TranscribeBusy>,
State(transcribe_tx): State<TranscribeSender>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
heal_orphans_if_idle(
&user_root,
&user.slug,
&analyze_busy,
&analyze_tx,
&transcribe_busy,
&transcribe_tx,
)
.await;
let busy = analyze_busy.0.load(Ordering::Acquire);
let cases = scan_user_cases(&config, &user.slug, busy).await;
let total = cases.len();
let groups = group_by_utc_date(cases);
let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root)
.await
.map(|(_, n)| n)
.unwrap_or(0);
let is_admin = user.is_admin();
MyCasesTemplate {
slug: user.slug,
groups,
total,
undo_count,
is_admin,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
pub async fn handle_case_detail(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(analyze_busy): State<AnalyzeBusy>,
State(analyze_tx): State<AnalyzeSender>,
State(transcribe_busy): State<TranscribeBusy>,
State(transcribe_tx): State<TranscribeSender>,
AxumPath(case_id): AxumPath<String>,
) -> Result<Html<String>, AppError> {
uuid::Uuid::parse_str(&case_id)
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
// IDOR guard: case_dir must live under the session's user_slug.
let user_root = config.data_path.join(&user.slug);
heal_orphans_if_idle(
&user_root,
&user.slug,
&analyze_busy,
&analyze_tx,
&transcribe_busy,
&transcribe_tx,
)
.await;
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)");
return Err(AppError::NotFound("Case not found".into()));
}
};
info!(slug = %user.slug, case_id = %case_id, "case detail viewed");
let recordings = scan_recordings(&case_dir).await;
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let a_busy = analyze_busy.0.load(Ordering::Acquire);
let t_busy = transcribe_busy.0.load(Ordering::Acquire);
let flags = compute_flags(&case_dir, &recordings, config.llm_configured(), a_busy).await;
let case_id_short = case_id.chars().take(8).collect();
let is_admin = user.is_admin();
CaseDetailTemplate {
slug: user.slug,
case_id,
case_id_short,
oneliner,
recordings,
can_analyze: flags.can_analyze,
llm_missing: flags.llm_missing,
analyzing: flags.analyzing,
has_document: flags.has_document,
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 soft-deleted (`.deleted` 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_deleted(&p).await {
return None;
}
Some(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. Soft-deleted cases (with
/// `.deleted` marker) are excluded.
async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> 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 {
Ok(r) => r,
Err(_) => return cases,
};
while let Ok(Some(case_entry)) = entries.next_entry().await {
let case_id = match case_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
let case_path = case_entry.path();
if !case_path.is_dir() {
continue;
}
if crate::paths::is_deleted(&case_path).await {
continue;
}
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
}
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 all_transcribed = non_failed_count > 0
&& recordings
.iter()
.filter(|r| !r.failed)
.all(|r| r.transcript.is_some());
let oneliner = tokio::fs::read_to_string(case_path.join("oneliner.txt"))
.await
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
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 time_hms_utc = extract_hhmm_utc(&most_recent);
let recorded_at_iso = recorded_at_iso_of(&most_recent);
cases.push(UserCaseView {
case_id,
most_recent,
time_hms_utc,
recorded_at_iso,
oneliner,
recordings_count,
analyzing,
has_document,
can_analyze,
});
}
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}