Refactor case detail into a dedicated page

This commit restructures the web interface for case details. The
previous `/web/cases/{case_id}` route, which previously showed both the
case summary and its recordings, has been split into two distinct pages:

- `/web/cases/{case_id}`: This page now displays the overall case
  information, including the document if available.
- `/web/cases/{case_id}/recordings`: This new page is dedicated to
  listing and displaying individual recordings within a case.

This change improves the organization and clarity of the web UI,
allowing for more focused views of case data. Additionally, the
`case_detail.html` and `document.html` templates have been removed as
their functionality is now handled by the new `case_page.html` and the
upcoming document rendering logic. The `cases.html` template has also
been removed, indicating a shift towards more granular page views.
This commit is contained in:
2026-04-19 17:11:29 +02:00
parent 3bb2d23adb
commit b7e54db54c
10 changed files with 326 additions and 445 deletions
+317
View File
@@ -0,0 +1,317 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use axum::body::Body;
use axum::http::{Request, StatusCode, header};
use doctate_server::config::{Config, User};
use tower::util::ServiceExt;
fn unique_tmp(label: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"doctate-case-page-{label}-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
))
}
fn make_user(slug: &str) -> User {
User {
slug: slug.into(),
api_key: format!("key-{slug}"),
web_password: bcrypt::hash("s", 4).unwrap(),
role: "doctor".into(),
whisper: Default::default(),
}
}
fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec<User>) -> Arc<Config> {
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
api_keys,
llm_url,
llm_api_key: "test-key".into(),
llm_model: "test-model".into(),
..Config::test_default()
})
}
fn config_with_llm(data_path: PathBuf) -> Arc<Config> {
config_with_llm_users(data_path, "http://unused".into(), vec![make_user("dr_a")])
}
fn config_without_llm(data_path: PathBuf) -> Arc<Config> {
let users = vec![make_user("dr_a")];
let api_keys: HashMap<String, String> = users
.iter()
.map(|u| (u.api_key.clone(), u.slug.clone()))
.collect();
Arc::new(Config {
data_path,
users,
api_keys,
..Config::test_default()
})
}
fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
let dir = data_path.join(slug).join(case_id);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn seed_recording(case_dir: &Path, ts_hms: &str, transcript: Option<&str>) {
let filename = format!("2026-04-15T{ts_hms}Z.m4a");
std::fs::write(case_dir.join(&filename), b"audio-bytes").unwrap();
if let Some(text) = transcript {
let tx_name = format!("2026-04-15T{ts_hms}Z.transcript.txt");
std::fs::write(case_dir.join(tx_name), text).unwrap();
}
}
async fn login(app: axum::Router, slug: &str) -> String {
let body = format!("slug={slug}&password=s");
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/web/login")
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
for v in resp.headers().get_all(header::SET_COOKIE).iter() {
let s = v.to_str().unwrap();
if let Some(pair) = s.split(';').next()
&& pair.starts_with("session=")
{
return pair.to_string();
}
}
panic!("no session cookie after login");
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
fn get_page(case_id: &str, cookie: &str, suffix: &str) -> Request<Body> {
Request::builder()
.uri(format!("/web/cases/{case_id}{suffix}"))
.header(header::COOKIE, cookie)
.body(Body::empty())
.unwrap()
}
// ---------------------------------------------------------------------
// GET /web/cases/{id} — case page
// ---------------------------------------------------------------------
#[tokio::test]
async fn case_page_shows_document_when_present() {
let config = config_with_llm(unique_tmp("cp-doc"));
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(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("der Inhalt"), "document body missing");
assert!(
body.contains(&format!("/web/cases/{case_id}/recordings")),
"recordings sub-page link missing"
);
}
#[tokio::test]
async fn case_page_document_has_copy_button() {
let config = config_with_llm(unique_tmp("cp-copy"));
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"), "kopiere mich").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(body.contains(r#"id="copy-btn""#), "copy button missing");
assert!(
body.contains(r#"id="doc-content""#),
"doc-content wrapper missing (JS depends on it)"
);
}
#[tokio::test]
async fn case_page_shows_analyze_button_when_transcribed_without_document() {
let config = config_with_llm(unique_tmp("cp-analyze"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("transkribierter Text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)),
"analyze form missing"
);
assert!(
!body.contains(r#"id="doc-content""#),
"doc-content must not appear without a document"
);
}
#[tokio::test]
async fn case_page_shows_llm_missing_hint_without_llm() {
let config = config_without_llm(unique_tmp("cp-llm"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("Text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
let body = body_string(resp).await;
assert!(
body.contains("LLM-Analyse nicht konfiguriert"),
"expected llm-missing hint"
);
}
#[tokio::test]
async fn case_page_empty_case_shows_placeholder() {
let config = config_with_llm(unique_tmp("cp-empty"));
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(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(
body.contains("Noch keine Aufnahmen vorhanden"),
"empty-case placeholder missing"
);
assert!(
body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)),
"delete form missing on empty case"
);
}
#[tokio::test]
async fn case_page_foreign_user_returns_404() {
let data_path = unique_tmp("cp-idor");
let users = vec![make_user("dr_a"), make_user("dr_b")];
let config = config_with_llm_users(data_path, "http://unused".into(), users);
let case_id = "22222222-2222-2222-2222-222222222222";
// Case exists under dr_b, but session is dr_a → 404.
let case_dir = seed_case(&config.data_path, "dr_b", case_id);
std::fs::write(case_dir.join("document.md"), "fremdes Dokument").unwrap();
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
// ---------------------------------------------------------------------
// GET /web/cases/{id}/recordings — recordings sub-page
// ---------------------------------------------------------------------
#[tokio::test]
async fn case_recordings_shows_all_recordings() {
let config = config_with_llm(unique_tmp("rec-list"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("erste Aufnahme"));
seed_recording(&case_dir, "11-00-00", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("2026-04-15T10-00-00Z.m4a"));
assert!(body.contains("2026-04-15T11-00-00Z.m4a"));
assert!(body.contains("erste Aufnahme"));
assert!(body.contains("Transkription ausstehend"));
}
#[tokio::test]
async fn case_recordings_back_link_targets_case_page() {
let config = config_with_llm(unique_tmp("rec-back"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", None);
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
assert!(
body.contains(&format!(r#"href="/web/cases/{case_id}""#)),
"back link must target the case page, not /web/cases"
);
}
#[tokio::test]
async fn case_recordings_has_no_action_buttons() {
let config = config_with_llm(unique_tmp("rec-ro"));
let case_id = "11111111-1111-1111-1111-111111111111";
let case_dir = seed_case(&config.data_path, "dr_a", case_id);
seed_recording(&case_dir, "10-00-00", Some("Text"));
let app = doctate_server::create_router(config);
let cookie = login(app.clone(), "dr_a").await;
let resp = app
.oneshot(get_page(case_id, &cookie, "/recordings"))
.await
.unwrap();
let body = body_string(resp).await;
assert!(
!body.contains(&format!(r#"action="/web/cases/{case_id}/analyze""#)),
"recordings page must not expose the analyze action"
);
assert!(
!body.contains(&format!(r#"action="/web/cases/{case_id}/delete""#)),
"recordings page must not expose the delete action"
);
}