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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user