feat: Add case closing and document viewing routes
Adds API endpoints for closing a case, which triggers an analysis job, and for viewing the generated document. This also includes: - New `AppError` variants: `Conflict` and `ServiceUnavailable`. - Helper functions for file handling, date parsing, and document searching. - Unit tests for helper functions.
This commit is contained in:
@@ -6,6 +6,11 @@ pub enum AppError {
|
|||||||
Unauthorized,
|
Unauthorized,
|
||||||
BadRequest(String),
|
BadRequest(String),
|
||||||
NotFound(String),
|
NotFound(String),
|
||||||
|
/// 409 — request is valid but conflicts with current state (e.g. an
|
||||||
|
/// analysis is already running for this case).
|
||||||
|
Conflict(String),
|
||||||
|
/// 503 — the feature is unavailable right now (e.g. no LLM configured).
|
||||||
|
ServiceUnavailable(String),
|
||||||
Internal(String),
|
Internal(String),
|
||||||
/// 302 redirect. Used by web auth to send unauthenticated users to login.
|
/// 302 redirect. Used by web auth to send unauthenticated users to login.
|
||||||
Redirect(String),
|
Redirect(String),
|
||||||
@@ -25,6 +30,8 @@ impl IntoResponse for AppError {
|
|||||||
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()),
|
Self::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_owned()),
|
||||||
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
|
||||||
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
|
||||||
|
Self::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
|
||||||
|
Self::ServiceUnavailable(msg) => (StatusCode::SERVICE_UNAVAILABLE, msg.clone()),
|
||||||
Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
Self::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
|
||||||
Self::Redirect(_) => unreachable!(),
|
Self::Redirect(_) => unreachable!(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
use axum::extract::{Path as AxumPath, State};
|
||||||
|
use axum::response::{Html, Redirect};
|
||||||
|
use time::format_description::well_known::Rfc3339;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::analyze::{AnalysisInput, AnalyzeJob, AnalyzeSender, RecordingInput};
|
||||||
|
use crate::auth::AuthenticatedWebUser;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::routes::user_web::locate_case;
|
||||||
|
|
||||||
|
/// POST /web/cases/{case_id}/close
|
||||||
|
///
|
||||||
|
/// Persist an `analysis_input_v1.json` for the case and enqueue an analyze
|
||||||
|
/// job. Redirects back to the case detail page where the UI will show
|
||||||
|
/// "wird analysiert …" until the worker writes `document_v1.md`.
|
||||||
|
pub async fn handle_close_case(
|
||||||
|
user: AuthenticatedWebUser,
|
||||||
|
State(config): State<Arc<Config>>,
|
||||||
|
State(analyze_tx): State<AnalyzeSender>,
|
||||||
|
AxumPath(case_id): AxumPath<String>,
|
||||||
|
) -> Result<Redirect, AppError> {
|
||||||
|
uuid::Uuid::parse_str(&case_id)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||||
|
|
||||||
|
// Server-side guard mirroring the UI gate: no close without a configured LLM.
|
||||||
|
if !config.llm_configured() {
|
||||||
|
return Err(AppError::ServiceUnavailable(
|
||||||
|
"LLM-Analyse nicht konfiguriert".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close only acts on cases in open/. A case in done/ is already finalized.
|
||||||
|
let user_root = config.data_path.join(&user.slug);
|
||||||
|
let case_dir = user_root.join("open").join(&case_id);
|
||||||
|
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
|
||||||
|
warn!(slug = %user.slug, case_id = %case_id, "close: case not found in open/");
|
||||||
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let input_path = case_dir.join("analysis_input_v1.json");
|
||||||
|
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Analyse läuft bereits für diesen Fall".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all active `.m4a` recordings and the transcripts next to them.
|
||||||
|
// `.m4a.failed` entries are deliberately skipped: they block nothing and
|
||||||
|
// carry no transcript. The doctor handles them manually.
|
||||||
|
let m4as = collect_m4as(&case_dir).await?;
|
||||||
|
if m4as.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("Keine Aufnahmen im Fall".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut recordings: Vec<RecordingInput> = Vec::new();
|
||||||
|
let mut last_mtime: SystemTime = SystemTime::UNIX_EPOCH;
|
||||||
|
for (m4a_path, mtime) in &m4as {
|
||||||
|
if *mtime > last_mtime {
|
||||||
|
last_mtime = *mtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcript_path = m4a_path.with_extension("transcript.txt");
|
||||||
|
let text = tokio::fs::read_to_string(&transcript_path)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::BadRequest("Nicht alle Aufnahmen sind transkribiert".into())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
// Skip blank transcripts from the LLM input — they add noise
|
||||||
|
// without signal. The `.m4a` still contributes to `last_mtime`.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let stem = m4a_path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||||
|
recordings.push(RecordingInput {
|
||||||
|
recorded_at: filename_stem_to_recorded_at(stem),
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if recordings.is_empty() {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Keine nicht-leeren Transkripte im Fall".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let last_recording_mtime = OffsetDateTime::from(last_mtime)
|
||||||
|
.format(&Rfc3339)
|
||||||
|
.map_err(|e| AppError::Internal(format!("format mtime: {e}")))?;
|
||||||
|
|
||||||
|
let input = AnalysisInput {
|
||||||
|
version: 1,
|
||||||
|
last_recording_mtime,
|
||||||
|
recordings,
|
||||||
|
};
|
||||||
|
|
||||||
|
write_input_create_new(&input_path, &input).await?;
|
||||||
|
|
||||||
|
// Unbounded send: only fails if the worker has gone away (programmer error
|
||||||
|
// or shutdown race). Log but don't fail the request — the file is already
|
||||||
|
// on disk and recovery will pick it up on next startup.
|
||||||
|
if analyze_tx
|
||||||
|
.send(AnalyzeJob {
|
||||||
|
case_dir: case_dir.clone(),
|
||||||
|
version: 1,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
warn!(case_id = %case_id, "analyze send failed (worker gone)");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
slug = %user.slug,
|
||||||
|
case_id = %case_id,
|
||||||
|
recordings = input.recordings.len(),
|
||||||
|
"close requested; analysis enqueued"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("/web/cases/{case_id}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>>,
|
||||||
|
AxumPath(case_id): AxumPath<String>,
|
||||||
|
) -> Result<Html<String>, AppError> {
|
||||||
|
uuid::Uuid::parse_str(&case_id)
|
||||||
|
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||||
|
|
||||||
|
let user_root = config.data_path.join(&user.slug);
|
||||||
|
let (case_dir, _status) = match locate_case(&user_root, &case_id).await {
|
||||||
|
Some(v) => v,
|
||||||
|
None => {
|
||||||
|
warn!(slug = %user.slug, case_id = %case_id, "document view: case not found");
|
||||||
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (version, content) = find_latest_document(&case_dir)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| AppError::NotFound("Noch kein Dokument erzeugt".into()))?;
|
||||||
|
|
||||||
|
// Minimal inline HTML for step 4; replaced with a proper template in step 7.
|
||||||
|
let escaped = content
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">");
|
||||||
|
Ok(Html(format!(
|
||||||
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Dokument v{version}</title></head><body><pre>{escaped}</pre><p><a href=\"/web/cases/{case_id}\">Zurück</a></p></body></html>"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return `(path, mtime)` for every `.m4a` file directly under `case_dir`,
|
||||||
|
/// sorted by filename (chronological, since filenames are UTC timestamps).
|
||||||
|
/// `.m4a.failed` entries are skipped.
|
||||||
|
async fn collect_m4as(case_dir: &Path) -> Result<Vec<(PathBuf, SystemTime)>, AppError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut entries = tokio::fs::read_dir(case_dir).await?;
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let filename = match entry.file_name().into_string() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !filename.ends_with(".m4a") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
let mtime = entry.metadata().await?.modified()?;
|
||||||
|
out.push((path, mtime));
|
||||||
|
}
|
||||||
|
out.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize `input` and write it to `path` using `create_new` to atomically
|
||||||
|
/// reserve the destination. Returns `Conflict` if another close already won
|
||||||
|
/// the race.
|
||||||
|
///
|
||||||
|
/// Trade-off: if the process crashes between `open` and `write_all`/`sync_all`,
|
||||||
|
/// a zero-byte or partially-written JSON remains. The worker will fail to
|
||||||
|
/// deserialize it and log an error — manual cleanup required. For MVP the
|
||||||
|
/// simpler check-and-create is acceptable.
|
||||||
|
async fn write_input_create_new(
|
||||||
|
path: &Path,
|
||||||
|
input: &AnalysisInput,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let bytes = serde_json::to_vec_pretty(input)
|
||||||
|
.map_err(|e| AppError::Internal(format!("serialize input: {e}")))?;
|
||||||
|
|
||||||
|
let mut f = match tokio::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(path)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
return Err(AppError::Conflict(
|
||||||
|
"Analyse läuft bereits für diesen Fall".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(AppError::Internal(e.to_string())),
|
||||||
|
};
|
||||||
|
f.write_all(&bytes).await?;
|
||||||
|
f.sync_all().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse the filesystem-safe timestamp mapping applied in `upload.rs`:
|
||||||
|
/// `:` in the time portion was replaced with `-`. Split at `T`, undo hyphens
|
||||||
|
/// only in the time part to avoid touching date hyphens.
|
||||||
|
fn filename_stem_to_recorded_at(stem: &str) -> String {
|
||||||
|
match stem.split_once('T') {
|
||||||
|
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
|
||||||
|
None => stem.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan `case_dir` for `document_v{N}.md`, pick the highest N, return its
|
||||||
|
/// content together with N. Returns `None` if no document exists.
|
||||||
|
async fn find_latest_document(case_dir: &Path) -> Option<(u32, String)> {
|
||||||
|
let mut highest: Option<u32> = None;
|
||||||
|
let mut entries = tokio::fs::read_dir(case_dir).await.ok()?;
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name_str) = name.to_str() else { continue };
|
||||||
|
let Some(version) = parse_document_version(name_str) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
highest = match highest {
|
||||||
|
Some(h) if h >= version => Some(h),
|
||||||
|
_ => Some(version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let v = highest?;
|
||||||
|
let content = tokio::fs::read_to_string(case_dir.join(format!("document_v{v}.md")))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
Some((v, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_document_version(filename: &str) -> Option<u32> {
|
||||||
|
let rest = filename.strip_prefix("document_v")?;
|
||||||
|
let num = rest.strip_suffix(".md")?;
|
||||||
|
if num.is_empty() || (num.starts_with('0') && num.len() > 1) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
num.parse::<u32>().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn filename_stem_roundtrip() {
|
||||||
|
assert_eq!(
|
||||||
|
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
|
||||||
|
"2026-04-15T10:32:00Z"
|
||||||
|
);
|
||||||
|
// Date-only hyphens are preserved.
|
||||||
|
assert_eq!(
|
||||||
|
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
|
||||||
|
"2026-04-15T10:32:00.123Z"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_valid_document_names() {
|
||||||
|
assert_eq!(parse_document_version("document_v1.md"), Some(1));
|
||||||
|
assert_eq!(parse_document_version("document_v42.md"), Some(42));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_invalid_document_names() {
|
||||||
|
assert_eq!(parse_document_version("document_v01.md"), None);
|
||||||
|
assert_eq!(parse_document_version("document_v.md"), None);
|
||||||
|
assert_eq!(parse_document_version("document_vx.md"), None);
|
||||||
|
assert_eq!(parse_document_version("analysis_input_v1.json"), None);
|
||||||
|
assert_eq!(parse_document_version(""), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
mod case_actions;
|
||||||
mod debug;
|
mod debug;
|
||||||
mod health;
|
mod health;
|
||||||
mod login;
|
mod login;
|
||||||
mod upload;
|
mod upload;
|
||||||
|
pub(crate) mod user_web;
|
||||||
pub(crate) mod web;
|
pub(crate) mod web;
|
||||||
mod user_web;
|
|
||||||
|
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
@@ -19,6 +20,14 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/web/logout", post(login::handle_logout))
|
.route("/web/logout", post(login::handle_logout))
|
||||||
.route("/web/cases", get(user_web::handle_my_cases))
|
.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_detail))
|
||||||
|
.route(
|
||||||
|
"/web/cases/{case_id}/close",
|
||||||
|
post(case_actions::handle_close_case),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/web/cases/{case_id}/document",
|
||||||
|
get(case_actions::handle_document_view),
|
||||||
|
)
|
||||||
.route("/web/", get(web::handle_case_list))
|
.route("/web/", get(web::handle_case_list))
|
||||||
.route(
|
.route(
|
||||||
"/web/audio/{user}/{case_id}/{filename}",
|
"/web/audio/{user}/{case_id}/{filename}",
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ pub async fn handle_case_detail(
|
|||||||
|
|
||||||
/// Locate a case directory under `<user_root>/{open|done}/<case_id>`.
|
/// Locate a case directory under `<user_root>/{open|done}/<case_id>`.
|
||||||
/// Returns the path and the status bucket it was found in.
|
/// Returns the path and the status bucket it was found in.
|
||||||
async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> {
|
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> {
|
||||||
for status in ["open", "done"] {
|
for status in ["open", "done"] {
|
||||||
let p = user_root.join(status).join(case_id);
|
let p = user_root.join(status).join(case_id);
|
||||||
if tokio::fs::try_exists(&p).await.unwrap_or(false) {
|
if tokio::fs::try_exists(&p).await.unwrap_or(false) {
|
||||||
|
|||||||
Reference in New Issue
Block a user