From 3bb2d23adb6182d67335b5bb257460df1a1b3022 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 19 Apr 2026 17:02:16 +0200 Subject: [PATCH] Refactor case pages into two distinct routes This commit separates the case detail page into two distinct routes: `/web/cases/{case_id}` for the main case information and document, and `/web/cases/{case_id}/recordings` for a dedicated view of audio files and their transcripts. This change improves the organization and clarity of the case viewing experience by segmenting the related but distinct information into their own UI sections. --- server/src/routes/user_web.rs | 147 ++++++++++++++++++++++++++ server/templates/case_page.html | 117 ++++++++++++++++++++ server/templates/case_recordings.html | 65 ++++++++++++ server/tests/analyze_test.rs | 55 ---------- server/tests/web_test.rs | 74 ------------- 5 files changed, 329 insertions(+), 129 deletions(-) create mode 100644 server/templates/case_page.html create mode 100644 server/templates/case_recordings.html diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2f31d53..614de2a 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -15,6 +15,7 @@ use crate::auth::AuthenticatedWebUser; use crate::case_id::{CaseId, CaseIdPath}; use crate::config::Config; use crate::error::AppError; +use crate::routes::case_actions::read_document; use crate::routes::web::{RecordingView, scan_recordings}; use crate::transcribe::recovery as transcribe_recovery; @@ -145,6 +146,38 @@ struct CaseDetailTemplate { is_admin: bool, } +#[derive(Template)] +#[template(path = "case_page.html")] +struct CasePageTemplate { + slug: String, + case_id: String, + case_id_short: String, + oneliner: Option, + /// 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, + recordings_count: usize, + transcribed_count: usize, + can_analyze: bool, + llm_missing: bool, + analyzing: bool, + has_document: bool, + is_admin: bool, +} + +#[derive(Template)] +#[template(path = "case_recordings.html")] +struct CaseRecordingsTemplate { + slug: String, + case_id: String, + case_id_short: String, + oneliner: Option, + recordings: Vec, + 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 @@ -293,6 +326,120 @@ pub async fn handle_case_detail( .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. +pub async fn handle_case_page( + user: AuthenticatedWebUser, + State(config): State>, + State(pipeline): State, + CaseIdPath(case_id): CaseIdPath, +) -> Result, AppError> { + 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 page (possible IDOR probe)", + ) + .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 = 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 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.is_some()).count(); + let case_id_short = case_id_str.chars().take(8).collect(); + let is_admin = user.is_admin(); + + CasePageTemplate { + slug: user.slug, + case_id: case_id_str, + case_id_short, + oneliner, + document_html, + recordings_count, + transcribed_count, + can_analyze: flags.can_analyze, + llm_missing: flags.llm_missing, + analyzing: flags.analyzing, + has_document: flags.has_document, + is_admin, + } + .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>, + State(pipeline): State, + CaseIdPath(case_id): CaseIdPath, +) -> Result, AppError> { + 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 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 = tokio::fs::read_to_string(case_dir.join("oneliner.txt")) + .await + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + 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 `//`. Returns `None` /// if the directory does not exist OR is soft-deleted (`.deleted` marker /// present). Both are treated as 404 / IDOR probe by callers. diff --git a/server/templates/case_page.html b/server/templates/case_page.html new file mode 100644 index 0000000..e835ca6 --- /dev/null +++ b/server/templates/case_page.html @@ -0,0 +1,117 @@ + + + + +Doctate — Fall {{ case_id_short }} + + + +
+ +
+
+

Fall {{ case_id_short }} {% if has_document %}ausgewertet{% else %}offen{% endif %}

+{% match oneliner %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +
Noch kein Oneliner.
+{% endmatch %} +
{{ case_id }}
+ +
+{% if analyzing %} +wird analysiert … +{% else if can_analyze %} +
+{% else if llm_missing && !has_document %} +LLM-Analyse nicht konfiguriert +{% endif %} +{% if is_admin %} +
+{% endif %} +
+
+ +{% match document_html %} +{% when Some with (html) %} +
+ +
+
{{ html|safe }}
+ +{% when None %} +{% if analyzing %} +
Wird analysiert …
+{% else if recordings_count == 0 %} +
Noch keine Aufnahmen vorhanden.
+{% else %} +
+{{ recordings_count }} Aufnahme{% if recordings_count != 1 %}n{% endif %}, davon {{ transcribed_count }} transkribiert. +
+{% endif %} +{% endmatch %} + + + + diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html new file mode 100644 index 0000000..b37784b --- /dev/null +++ b/server/templates/case_recordings.html @@ -0,0 +1,65 @@ + + + + +Doctate — Aufnahmen Fall {{ case_id_short }} + + + +
+ +
+
+

Aufnahmen

+{% match oneliner %} +{% when Some with (t) %} +
{{ t }}
+{% when None %} +{% endmatch %} +
{{ case_id }}
+ +

Aufnahmen ({{ recordings.len() }})

+{% for rec in recordings %} +
+
+{{ rec.filename }} + +
+{% if rec.failed %} +
Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.
+{% else %} +{% match rec.transcript %} +{% when Some with (t) %} +{% if t.is_empty() %} +
(stille Aufnahme)
+{% else %} +
{{ t }}
+{% endif %} +{% when None %} +{% if transcribe_busy %} +
Transkription läuft…
+{% else %} +
Transkription ausstehend.
+{% endif %} +{% endmatch %} +{% endif %} +
+{% endfor %} + + diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 7d4d9ae..6855502 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -557,39 +557,6 @@ async fn recovery_skips_completed_analysis() { ); } -// --------------------------------------------------------------------- -// Document view -// --------------------------------------------------------------------- - -#[tokio::test] -async fn document_view_reads_document() { - let config = config_with_llm(unique_tmp("dv"), "http://unused".into()); - let case_id = "11111111-1111-1111-1111-111111111111"; - let case_dir = seed_case(&config.data_path, "dr_a", case_id); - std::fs::write(case_dir.join("document.md"), "der Inhalt").unwrap(); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app - .oneshot( - Request::builder() - .uri(format!("/web/cases/{case_id}/document")) - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - let body_str = String::from_utf8(body.to_vec()).unwrap(); - assert!(body_str.contains("der Inhalt"), "expected content in body"); - assert!(body_str.contains("Dokument")); -} - // --------------------------------------------------------------------- // Re-analysis path: same handler, version derived from existing documents // --------------------------------------------------------------------- @@ -827,28 +794,6 @@ async fn recovery_skips_deleted_cases() { assert!(rx.try_recv().is_err(), "deleted case must not be enqueued"); } -#[tokio::test] -async fn document_view_without_document_returns_404() { - let config = config_with_llm(unique_tmp("dv2"), "http://unused".into()); - let case_id = "11111111-1111-1111-1111-111111111111"; - seed_case(&config.data_path, "dr_a", case_id); - - let app = doctate_server::create_router(config); - let cookie = login(app.clone(), "dr_a").await; - - let resp = app - .oneshot( - Request::builder() - .uri(format!("/web/cases/{case_id}/document")) - .header(header::COOKIE, &cookie) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); -} - // --------------------------------------------------------------------- // Admin-only reset // --------------------------------------------------------------------- diff --git a/server/tests/web_test.rs b/server/tests/web_test.rs index 8f8989d..87ee98a 100644 --- a/server/tests/web_test.rs +++ b/server/tests/web_test.rs @@ -27,80 +27,6 @@ fn test_config() -> Arc { }) } -async fn body_to_string(response: axum::response::Response) -> String { - let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - String::from_utf8(bytes.to_vec()).unwrap() -} - -#[tokio::test] -async fn web_index_empty() { - let config = test_config(); - let app = doctate_server::create_router(config); - - let response = app - .oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response).await; - assert!(body.contains("No cases found")); -} - -#[tokio::test] -async fn web_index_shows_cases() { - let config = test_config(); - let data_path = config.data_path.clone(); - - // Pre-create cases on disk - let case1 = "550e8400-e29b-41d4-a716-446655440000"; - let case2 = "660e8400-e29b-41d4-a716-446655440000"; - - let open_case = data_path.join("dr_test").join(case1); - std::fs::create_dir_all(&open_case).unwrap(); - std::fs::write(open_case.join("2026-04-13T10-30-00Z.m4a"), b"fake audio 1").unwrap(); - - let done_case = data_path.join("dr_test").join(case2); - std::fs::create_dir_all(&done_case).unwrap(); - std::fs::write(done_case.join("2026-04-12T09-00-00Z.m4a"), b"fake audio 2").unwrap(); - std::fs::write( - done_case.join("2026-04-12T09-00-00Z.transcript.txt"), - "Herzkatheter ohne Befund.", - ) - .unwrap(); - std::fs::write(done_case.join("oneliner.txt"), "Herzkatheter unauffällig").unwrap(); - - let app = doctate_server::create_router(config); - let response = app - .oneshot(Request::builder().uri("/web/").body(Body::empty()).unwrap()) - .await - .unwrap(); - - assert_eq!(response.status(), StatusCode::OK); - let body = body_to_string(response).await; - - assert!(body.contains(case1)); - assert!(body.contains(case2)); - assert!(body.contains("dr_test")); - assert!(body.contains("