434 lines
15 KiB
Rust
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,
|
|
})
|
|
}
|