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:
+1
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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">← 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>
|
||||
@@ -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>
|
||||
@@ -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">← 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user