Files
doctate/server/src/routes/user_web.rs
T
2026-04-19 15:35:10 +02:00

434 lines
15 KiB
Rust

use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use askama::Template;
use axum::extract::State;
use axum::response::Html;
use tracing::{info, warn};
use time::{Date, OffsetDateTime, format_description::well_known::Rfc3339};
use crate::PipelineState;
use crate::analyze::{ANALYSIS_INPUT_FILE, DOCUMENT_FILE, recovery as analyze_recovery};
use crate::auth::AuthenticatedWebUser;
use crate::case_id::{CaseId, CaseIdPath};
use crate::config::Config;
use crate::error::AppError;
use crate::routes::web::{RecordingView, scan_recordings};
use crate::transcribe::recovery as transcribe_recovery;
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.
impl PipelineState {
pub async fn heal_orphans_if_idle(&self, user_root: &Path, slug: &str) {
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;
}
}
}
pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
State(pipeline): State<PipelineState>,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
let busy = pipeline.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(pipeline): State<PipelineState>,
CaseIdPath(case_id): CaseIdPath,
) -> Result<Html<String>, AppError> {
// IDOR guard: case_dir must live under the session's user_slug.
let user_root = config.data_path.join(&user.slug);
pipeline.heal_orphans_if_idle(&user_root, &user.slug).await;
let case_dir = locate_case_or_404(
&user_root,
&case_id,
&user.slug,
"case detail (possible IDOR probe)",
)
.await?;
let case_id_str = case_id.to_string();
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 = pipeline.analyze_busy.0.load(Ordering::Acquire);
let t_busy = pipeline.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_str.chars().take(8).collect();
let is_admin = user.is_admin();
CaseDetailTemplate {
slug: user.slug,
case_id: case_id_str,
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)
}
/// 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()))
}
}
}
/// 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(entry)) = entries.next_entry().await {
let Some((case_id, case_path)) = filter_valid_case_dir(entry).await else {
continue;
};
if let Some(view) =
compute_case_view(case_id, &case_path, llm_configured, worker_busy).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 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)> {
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 crate::paths::is_deleted(&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,
) -> 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 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);
Some(UserCaseView {
case_id,
most_recent,
time_hms_utc,
recorded_at_iso,
oneliner,
recordings_count,
analyzing,
has_document,
can_analyze,
})
}