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 -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 {