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("