From 6369ff1680e02777962599dceeeec687d31efbee Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 19 Apr 2026 17:33:29 +0200 Subject: [PATCH] feat: Add admin flag to user and templates Passes an `is_admin` flag to the `CaseRecordingsTemplate` and `case_page.html`. This flag determines whether the full case ID is displayed in the meta section for administrative users. It also influences the header display on the case page to prioritize the oneliner when available. --- server/src/routes/user_web.rs | 3 + server/templates/case_page.html | 47 ++++++++------ server/templates/case_recordings.html | 24 +++++--- server/tests/case_page_test.rs | 88 +++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 30 deletions(-) diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 2e1b96d..49b0ecb 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -153,6 +153,7 @@ struct CaseRecordingsTemplate { oneliner: Option, recordings: Vec, transcribe_busy: bool, + is_admin: bool, } /// Flags derived from filesystem state + config. @@ -349,6 +350,7 @@ pub async fn handle_case_recordings( 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, @@ -357,6 +359,7 @@ pub async fn handle_case_recordings( oneliner, recordings, transcribe_busy: t_busy, + is_admin, } .render() .map(Html) diff --git a/server/templates/case_page.html b/server/templates/case_page.html index e835ca6..ce3459e 100644 --- a/server/templates/case_page.html +++ b/server/templates/case_page.html @@ -8,9 +8,9 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1 header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } .back { color: #4a90e2; text-decoration: none; } -.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; } -.meta { color: #666; font-family: monospace; font-size: 0.9em; } -.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; } +h1 { display: flex; align-items: center; gap: 0.6em; flex-wrap: wrap; } +.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } +.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.6em; font-weight: bold; color: white; vertical-align: middle; } .status-badge.open { background: #4a90e2; } .status-badge.done { background: #7ed321; } .actions { margin: 1em 0 1.5em; display: flex; align-items: center; gap: 0.4em; } @@ -23,14 +23,17 @@ header form { margin: 0; } .status-panel { margin: 1em 0; padding: 0.8em 1em; background: #f6f6f6; border-radius: 4px; } .recordings-link { display: inline-block; margin: 1em 0; color: #4a90e2; text-decoration: none; font-size: 0.95em; } .recordings-link:hover { text-decoration: underline; } -.doc-content { font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; } +.doc-content { position: relative; font-family: serif; font-size: 1.05em; line-height: 1.55; background: #f6f6f6; padding: 1em 1.2em; border-radius: 4px; } .doc-content p { margin: 0.8em 0; } .doc-content p:first-child { margin-top: 0; } .doc-content p:last-child { margin-bottom: 0; } .doc-content mark { background: #fff3a8; padding: 0 0.2em; border-radius: 2px; } -.copy-btn { padding: 0.5em 1em; font-size: 1em; border: 1px solid #4a90e2; background: white; color: #4a90e2; border-radius: 4px; cursor: pointer; font-weight: bold; } -.copy-btn:hover { background: #eaf3fc; } -.toolbar { display: flex; gap: 0.6em; margin: 1em 0; } +.copy-btn { position: absolute; bottom: 0.5em; right: 0.5em; padding: 0.35em; background: transparent; border: none; border-radius: 4px; cursor: pointer; color: #888; display: inline-flex; align-items: center; justify-content: center; line-height: 0; opacity: 0.45; transition: opacity 0.15s, color 0.15s; } +.copy-btn:hover { color: #4a90e2; opacity: 1; } +.copy-btn .icon-check { display: none; } +.copy-btn.copied { color: #2d8c2d; opacity: 1; } +.copy-btn.copied .icon-copy { display: none; } +.copy-btn.copied .icon-check { display: block; } @@ -38,14 +41,14 @@ header form { margin: 0; }
← Fälle
-

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

+

{% match oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -
Noch kein Oneliner.
+{% when Some with (t) %}{{ t }} +{% when None %}Fall {% endmatch %} -
{{ case_id }}
+{% if has_document %}ausgewertet{% else %}offen{% endif %} +

+{% if is_admin %}
{{ case_id }}
{% endif %}
{% if analyzing %} @@ -63,18 +66,20 @@ header form { margin: 0; } {% match document_html %} {% when Some with (html) %} -
- +
+ +
{{ html|safe }}
-
{{ html|safe }}
diff --git a/server/templates/case_recordings.html b/server/templates/case_recordings.html index b37784b..25de214 100644 --- a/server/templates/case_recordings.html +++ b/server/templates/case_recordings.html @@ -7,9 +7,10 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; } header { display: flex; justify-content: space-between; align-items: center; } header form { margin: 0; } -.back { color: #4a90e2; text-decoration: none; } -.oneliner { font-size: 1.1em; color: #222; margin: 0.6em 0 1em; } -.meta { color: #666; font-family: monospace; font-size: 0.9em; } +.breadcrumb { display: flex; gap: 0.8em; flex-wrap: wrap; } +.breadcrumb a { color: #4a90e2; text-decoration: none; } +.breadcrumb a:hover { text-decoration: underline; } +.meta { color: #999; font-family: monospace; font-size: 0.8em; margin: 0 0 1em; } .recording { margin: 1em 0; padding: 0.7em; border: 1px solid #ddd; border-radius: 4px; } .recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; } .recording-head { display: flex; align-items: center; gap: 1em; flex-wrap: wrap; } @@ -21,16 +22,19 @@ header form { margin: 0; }
- +

Aufnahmen

-{% match oneliner %} -{% when Some with (t) %} -
{{ t }}
-{% when None %} -{% endmatch %} -
{{ case_id }}
+{% if is_admin %}
{{ case_id }}
{% endif %}

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

{% for rec in recordings %} diff --git a/server/tests/case_page_test.rs b/server/tests/case_page_test.rs index 8458943..8178335 100644 --- a/server/tests/case_page_test.rs +++ b/server/tests/case_page_test.rs @@ -25,6 +25,16 @@ fn make_user(slug: &str) -> User { } } +fn make_admin(slug: &str) -> User { + let mut u = make_user(slug); + u.role = "admin".into(); + u +} + +fn seed_oneliner(case_dir: &Path, text: &str) { + std::fs::write(case_dir.join("oneliner.txt"), text).unwrap(); +} + fn config_with_llm_users(data_path: PathBuf, llm_url: String, users: Vec) -> Arc { let api_keys: HashMap = users .iter() @@ -290,6 +300,84 @@ async fn case_recordings_back_link_targets_case_page() { ); } +#[tokio::test] +async fn case_page_uses_oneliner_as_h1_when_present() { + let config = config_with_llm(unique_tmp("cp-h1")); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_oneliner(&case_dir, "Herzkatheter unauffällig"); + std::fs::write(case_dir.join("document.md"), "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(); + let body = body_string(resp).await; + + // H1 wraps the oneliner; the short case_id fallback must be gone. + assert!( + body.contains("

") && body.contains("Herzkatheter unauffällig"), + "h1 should show the oneliner" + ); + // The prominent meta block with the full UUID is admin-only. + assert!( + !body.contains(r#"
"#), + "non-admin must not see the full case_id meta block" + ); +} + +#[tokio::test] +async fn case_page_admin_sees_full_case_id_meta() { + let data_path = unique_tmp("cp-admin"); + let users = vec![make_admin("dr_admin")]; + let config = config_with_llm_users(data_path, "http://unused".into(), users); + let case_id = "11111111-1111-1111-1111-111111111111"; + seed_case(&config.data_path, "dr_admin", case_id); + + let app = doctate_server::create_router(config); + let cookie = login(app.clone(), "dr_admin").await; + + let resp = app.oneshot(get_page(case_id, &cookie, "")).await.unwrap(); + let body = body_string(resp).await; + + assert!( + body.contains(case_id), + "admin meta block should expose the full case_id" + ); +} + +#[tokio::test] +async fn case_recordings_breadcrumb_includes_oneliner() { + let config = config_with_llm(unique_tmp("rec-breadcrumb")); + let case_id = "11111111-1111-1111-1111-111111111111"; + let case_dir = seed_case(&config.data_path, "dr_a", case_id); + seed_oneliner(&case_dir, "Kurzer Befund"); + 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; + + // Two separate breadcrumb links: back to the case list and back to the case page. + assert!( + body.contains(r#"href="/web/cases""#), + "breadcrumb must link back to the case list" + ); + assert!( + body.contains(&format!(r#"href="/web/cases/{case_id}""#)), + "breadcrumb must link back to the case page" + ); + assert!( + body.contains("Kurzer Befund"), + "breadcrumb must show the oneliner text" + ); +} + #[tokio::test] async fn case_recordings_has_no_action_buttons() { let config = config_with_llm(unique_tmp("rec-ro"));