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
+1 -1
View File
@@ -83,7 +83,7 @@ show_case() {
fi
echo
echo "Browse: $SERVER_URL/web/"
echo "Browse: $SERVER_URL/web/cases"
}
# Record + upload for the given case, then display the case state.
+1 -35
View File
@@ -2,9 +2,8 @@ use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use askama::Template;
use axum::extract::State;
use axum::response::{Html, Redirect};
use axum::response::Redirect;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
use tokio::io::AsyncWriteExt;
@@ -22,13 +21,6 @@ use crate::error::AppError;
use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker};
use crate::routes::user_web::locate_case_or_404;
#[derive(Template)]
#[template(path = "document.html")]
struct DocumentTemplate {
case_id: String,
content: String,
}
/// POST /web/cases/{case_id}/analyze
///
/// Trigger an LLM analysis for the case. If no document exists yet, writes
@@ -87,32 +79,6 @@ pub async fn handle_analyze_case(
Ok(Redirect::to("/web/cases"))
}
/// GET /web/cases/{case_id}/document
///
/// Render the latest `document_v{N}.md` for the case. The handler scans the
/// case directory and picks the highest N — no symlink, no separate pointer.
pub async fn handle_document_view(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
CaseIdPath(case_id): CaseIdPath,
) -> Result<Html<String>, AppError> {
let user_root = config.data_path.join(&user.slug);
let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "document view").await?;
let md = read_document(&case_dir)
.await
.ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?;
let content = crate::analyze::render::md_to_html(&md);
DocumentTemplate {
case_id: case_id.to_string(),
content,
}
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
/// Build an `AnalysisInput` for the given case directory.
/// Collects all `.m4a` (excluding `.m4a.failed`), reads the matching
/// `.transcript.txt` for each, skips blank transcripts, and computes
+5 -6
View File
@@ -28,7 +28,11 @@ pub fn api_router() -> Router<AppState> {
)
.route("/web/logout", post(login::handle_logout))
.route("/web/cases", get(user_web::handle_my_cases))
.route("/web/cases/{case_id}", get(user_web::handle_case_detail))
.route("/web/cases/{case_id}", get(user_web::handle_case_page))
.route(
"/web/cases/{case_id}/recordings",
get(user_web::handle_case_recordings),
)
.route(
"/web/cases/{case_id}/analyze",
post(case_actions::handle_analyze_case),
@@ -46,11 +50,6 @@ pub fn api_router() -> Router<AppState> {
post(case_actions::handle_undo_delete),
)
.route("/web/cases/bulk", post(bulk::handle_bulk_action))
.route(
"/web/cases/{case_id}/document",
get(case_actions::handle_document_view),
)
.route("/web/", get(web::handle_case_list))
.route(
"/web/audio/{user}/{case_id}/{filename}",
get(web::handle_audio),
-77
View File
@@ -125,31 +125,9 @@ struct MyCasesTemplate {
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,
}
#[derive(Template)]
#[template(path = "case_page.html")]
struct CasePageTemplate {
slug: String,
case_id: String,
case_id_short: String,
oneliner: Option<String>,
@@ -175,7 +153,6 @@ struct CaseRecordingsTemplate {
oneliner: Option<String>,
recordings: Vec<RecordingView>,
transcribe_busy: bool,
is_admin: bool,
}
/// Flags derived from filesystem state + config.
@@ -275,57 +252,6 @@ pub async fn handle_my_cases(
.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}")))
}
/// GET /web/cases/{case_id}
///
/// Canonical case page. Renders the document inline if present; otherwise
@@ -374,7 +300,6 @@ pub async fn handle_case_page(
let is_admin = user.is_admin();
CasePageTemplate {
slug: user.slug,
case_id: case_id_str,
case_id_short,
oneliner,
@@ -424,7 +349,6 @@ 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,
@@ -433,7 +357,6 @@ pub async fn handle_case_recordings(
oneliner,
recordings,
transcribe_busy: t_busy,
is_admin,
}
.render()
.map(Html)
+1 -107
View File
@@ -1,16 +1,13 @@
use std::path::Path;
use std::sync::Arc;
use askama::Template;
use axum::body::Body;
use axum::extract::{Path as AxumPath, State};
use axum::http::header;
use axum::response::{Html, Response};
use tracing::warn;
use axum::response::Response;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::user_web::any_document_exists;
pub(crate) struct RecordingView {
pub(crate) filename: String,
@@ -20,31 +17,6 @@ pub(crate) struct RecordingView {
pub(crate) failed: bool,
}
struct CaseView {
user_slug: String,
case_id: String,
case_id_short: String,
has_document: bool,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
}
#[derive(Template)]
#[template(path = "cases.html")]
struct CasesTemplate {
cases: Vec<CaseView>,
}
pub async fn handle_case_list(State(config): State<Arc<Config>>) -> Result<Html<String>, AppError> {
let cases = scan_cases(&config.data_path).await;
let template = CasesTemplate { cases };
template
.render()
.map(Html)
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
}
pub async fn handle_audio(
State(config): State<Arc<Config>>,
AxumPath((user, case_id, filename)): AxumPath<(String, String, String)>,
@@ -86,84 +58,6 @@ fn validate_filename(filename: &str) -> Result<(), AppError> {
Ok(())
}
/// Scan the data directory for all cases across all users.
/// Silently skips invalid entries so a single broken directory does not break the page.
async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
let mut cases = Vec::new();
let mut users = match tokio::fs::read_dir(data_path).await {
Ok(r) => r,
Err(_) => return cases, // data_path may not exist yet
};
while let Ok(Some(user_entry)) = users.next_entry().await {
let user_slug = match user_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if validate_user_slug(&user_slug).is_err() {
continue;
}
if !user_entry.path().is_dir() {
continue;
}
let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await {
Ok(r) => r,
Err(_) => continue,
};
while let Ok(Some(case_entry)) = case_entries.next_entry().await {
let case_id = match case_entry.file_name().into_string() {
Ok(s) => s,
Err(_) => continue,
};
if uuid::Uuid::parse_str(&case_id).is_err() {
warn!(case_id = %case_id, "Skipping non-UUID directory");
continue;
}
if !case_entry.path().is_dir() {
continue;
}
let case_path = case_entry.path();
if crate::paths::is_deleted(&case_path).await {
continue;
}
let recordings = scan_recordings(&case_path).await;
if recordings.is_empty() {
continue;
}
let most_recent = recordings
.last()
.map(|r| r.filename.clone())
.unwrap_or_default();
let case_id_short = case_id.chars().take(8).collect();
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;
cases.push(CaseView {
user_slug: user_slug.clone(),
case_id,
case_id_short,
has_document,
most_recent,
oneliner,
recordings,
});
}
}
// Most recent case first.
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}
pub(crate) async fn scan_recordings(case_dir: &Path) -> Vec<RecordingView> {
let mut recordings = Vec::new();
let mut entries = match tokio::fs::read_dir(case_dir).await {
-89
View File
@@ -1,89 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Fall {{ case_id_short }}</title>
<style>
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; }
.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; }
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
.transcript { margin: 0.6em 0 0; padding: 0.6em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
.pending { color: #888; font-style: italic; margin-top: 0.5em; }
.failed { color: #b00; font-weight: bold; margin-top: 0.5em; }
.status-badge { display: inline-block; padding: 0.1em 0.6em; border-radius: 999px; font-size: 0.8em; font-weight: bold; color: white; }
.status-badge.open { background: #4a90e2; }
.status-badge.done { background: #7ed321; }
.actions { margin: 1em 0 1.5em; }
.actions form { margin: 0; display: inline; }
.actions button { font-size: 1em; padding: 0.5em 1em; }
.actions a.doc-link { display: inline-block; padding: 0.5em 1em; background: #7ed321; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; }
.actions .analyzing { color: #888; font-style: italic; }
.actions .llm-missing { color: #a66; font-style: italic; }
</style>
</head>
<body>
<header>
<div><a class="back" href="/web/cases">&larr; Zurück</a></div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Fall {{ case_id_short }} {% if has_document %}<span class="status-badge done">ausgewertet</span>{% else %}<span class="status-badge open">offen</span>{% endif %}</h1>
{% match oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
<div class="oneliner" style="color:#888;font-style:italic">Noch kein Oneliner.</div>
{% endmatch %}
<div class="meta">{{ case_id }}</div>
<div class="actions">
{% if analyzing %}
<span class="analyzing">wird analysiert …</span>
{% else if can_analyze && !has_document %}
<form method="post" action="/web/cases/{{ case_id }}/analyze"><button type="submit">Analysieren</button></form>
{% else if llm_missing && !has_document %}
<span class="llm-missing">LLM-Analyse nicht konfiguriert</span>
{% endif %}
{% if is_admin %}
<form method="post" action="/web/cases/{{ case_id }}/reset" style="display:inline"><button type="submit">Reset</button></form>
{% endif %}
<form method="post" action="/web/cases/{{ case_id }}/delete" style="margin-left:auto;display:inline"><button type="submit">Entfernen</button></form>
</div>
<h2>Aufnahmen ({{ recordings.len() }})</h2>
{% for rec in recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<span>{{ rec.filename }}</span>
<audio controls preload="none">
<source src="/web/audio/{{ slug }}/{{ case_id }}/{{ rec.filename }}" type="audio/mp4">
</audio>
</div>
{% if rec.failed %}
<div class="failed">Transkription fehlgeschlagen — .failed-Suffix entfernen zum Erneut-Versuchen.</div>
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
{% if t.is_empty() %}
<div class="pending">(stille Aufnahme)</div>
{% else %}
<div class="transcript">{{ t }}</div>
{% endif %}
{% when None %}
{% if transcribe_busy %}
<div class="pending">Transkription läuft…</div>
{% else %}
<div class="pending">Transkription ausstehend.</div>
{% endif %}
{% endmatch %}
{% endif %}
</div>
{% endfor %}
</body>
</html>
-61
View File
@@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Doctate — Cases</title>
<style>
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
.case { border: 1px solid #ccc; margin: 1em 0; padding: 1em; border-radius: 4px; }
.case h2 { margin: 0 0 0.3em 0; font-size: 1.1em; }
.case small { color: #666; font-family: monospace; }
.status-open { border-left: 4px solid #4a90e2; }
.status-done { border-left: 4px solid #7ed321; }
.recording { margin: 0.5em 0; }
.recording-head { display: flex; align-items: center; gap: 1em; }
.recording-head span { font-family: monospace; font-size: 0.9em; min-width: 20em; }
.transcript { margin: 0.5em 0 0 1em; padding: 0.5em; background: #f6f6f6; border-radius: 4px; white-space: pre-wrap; font-family: serif; }
.pending { margin: 0.5em 0 0 1em; color: #888; font-style: italic; }
.failed { margin: 0.5em 0 0 1em; color: #b00; font-weight: bold; }
.recording.failed-row { background: #fff7f7; border-left: 3px solid #e24a4a; padding-left: 0.5em; }
.oneliner { font-size: 1em; color: #333; margin: 0 0 0.3em 0; }
</style>
</head>
<body>
<h1>Cases</h1>
{% if cases.is_empty() %}
<p>No cases found.</p>
{% else %}
{% for case in cases %}
<div class="case {% if case.has_document %}status-done{% else %}status-open{% endif %}">
<h2>{{ case.user_slug }} — {{ case.case_id_short }} ({% if case.has_document %}analyzed{% else %}in progress{% endif %})</h2>
{% match case.oneliner %}
{% when Some with (t) %}
<div class="oneliner">{{ t }}</div>
{% when None %}
{% endmatch %}
<small>{{ case.case_id }}</small>
{% for rec in case.recordings %}
<div class="recording{% if rec.failed %} failed-row{% endif %}">
<div class="recording-head">
<span>{{ rec.filename }}</span>
<audio controls preload="none">
<source src="/web/audio/{{ case.user_slug }}/{{ case.case_id }}/{{ rec.filename }}" type="audio/mp4">
</audio>
</div>
{% if rec.failed %}
<div class="failed">Transcription failed — rename away the .failed suffix to retry.</div>
{% else %}
{% match rec.transcript %}
{% when Some with (t) %}
<div class="transcript">{{ t }}</div>
{% when None %}
<div class="pending">Transcription pending…</div>
{% endmatch %}
{% endif %}
</div>
{% endfor %}
</div>
{% endfor %}
{% endif %}
</body>
</html>
-68
View File
@@ -1,68 +0,0 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Doctate — Dokument</title>
<style>
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-button { display: inline-block; padding: 0.5em 1em; background: #4a90e2; color: white; text-decoration: none; border-radius: 4px; font-weight: bold; }
.doc-content { 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; }
.meta { color: #666; font-family: monospace; font-size: 0.9em; margin-bottom: 1em; }
.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; }
</style>
</head>
<body>
<header>
<div><a class="back-button" href="/web/cases">&larr; Alle Fälle</a></div>
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
</header>
<h1>Dokument</h1>
<div class="meta">{{ case_id }}</div>
<div class="toolbar">
<button id="copy-btn" type="button" class="copy-btn" hidden>In Zwischenablage</button>
</div>
<div id="doc-content" class="doc-content">{{ content|safe }}</div>
<div class="toolbar">
<a class="back-button" href="/web/cases/{{ case_id }}">Aufnahmen</a>
</div>
<script>
(() => {
const btn = document.getElementById('copy-btn');
if (!btn) return;
btn.hidden = false;
const label = btn.textContent;
btn.addEventListener('click', async () => {
const text = document.getElementById('doc-content').innerText;
let ok = false;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
ok = true;
}
} catch (_) { /* fall through */ }
if (!ok) {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.top = '-1000px';
document.body.appendChild(ta);
ta.select();
try { ok = document.execCommand('copy'); } catch (_) {}
ta.remove();
}
btn.textContent = ok ? 'Kopiert ✓' : 'Fehlgeschlagen';
setTimeout(() => { btn.textContent = label; }, 1500);
});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -67,7 +67,7 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
<li class="case-row {% if case.has_document %}done{% else %}open{% endif %}">
<div class="check"><input type="checkbox" name="case_id" value="{{ case.case_id }}"></div>
<div class="body">
{% if case.has_document %}<a href="/web/cases/{{ case.case_id }}/document">{% else %}<a href="/web/cases/{{ case.case_id }}">{% endif %}
<a href="/web/cases/{{ case.case_id }}">
<div class="line1"><span class="time"><time datetime="{{ case.recorded_at_iso }}">{{ case.time_hms_utc }}</time></span>{% match case.oneliner %}{% when Some with (t) %} — {{ t }}{% when None %} — <span class="empty">kein Oneliner</span>{% endmatch %}</div>
<div class="line2">{{ case.recordings_count }} Aufnahmen{% if case.has_document %} — <span class="status-badge done">ausgewertet</span>{% else %} — <span class="status-badge open">offen</span>{% endif %}{% if case.analyzing %} <span class="label analyzing">wird analysiert</span>{% endif %}</div>
{% if is_admin %}<div class="uuid">{{ case.case_id }}</div>{% endif %}
+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"
);
}