Refactor: Simplify case directory structure
The distinction between `open/` and `done/` directories for cases has been removed. All cases for a user now reside directly under the user's directory (e.g., `<data_path>/<slug>/<case_id>/`). This simplifies path management and eliminates redundant directory traversals. Key changes include: - Removed `open/` and `done/` subdirectories in path resolution. - Introduced a `paths::case_dir` function as a single source of truth for case directory layout. - Updated various modules (`recovery`, `auth`, `routes`, `transcribe`, `tests`) to use the new path structure. - Adjusted templates to reflect the simplified case status representation.
This commit is contained in:
@@ -4,7 +4,7 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use super::{AnalyzeJob, AnalyzeSender};
|
use super::{AnalyzeJob, AnalyzeSender};
|
||||||
|
|
||||||
/// Walk `data_path/*/open/*/analysis_input_v*.json` and enqueue every input
|
/// Walk `data_path/*/*/analysis_input_v*.json` and enqueue every input
|
||||||
/// that does not yet have a matching `document_v*.md` sibling.
|
/// that does not yet have a matching `document_v*.md` sibling.
|
||||||
///
|
///
|
||||||
/// Intended to run once at startup. Send is synchronous on the unbounded
|
/// Intended to run once at startup. Send is synchronous on the unbounded
|
||||||
@@ -33,8 +33,7 @@ async fn collect_pending(data_path: &Path) -> Vec<(PathBuf, u32)> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
while let Ok(Some(user_entry)) = users.next_entry().await {
|
while let Ok(Some(user_entry)) = users.next_entry().await {
|
||||||
let open_dir = user_entry.path().join("open");
|
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
|
||||||
let mut cases = match tokio::fs::read_dir(&open_dir).await {
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-2
@@ -59,8 +59,7 @@ where
|
|||||||
.ok_or(AppError::Unauthorized)?;
|
.ok_or(AppError::Unauthorized)?;
|
||||||
|
|
||||||
let data_dir = config.data_path.join(slug);
|
let data_dir = config.data_path.join(slug);
|
||||||
tokio::fs::create_dir_all(data_dir.join("open")).await?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
tokio::fs::create_dir_all(data_dir.join("done")).await?;
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
slug: slug.clone(),
|
slug: slug.clone(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod auth;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
pub mod paths;
|
||||||
pub mod routes;
|
pub mod routes;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
pub mod web_session;
|
pub mod web_session;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Absolute on-disk location of a case directory.
|
||||||
|
///
|
||||||
|
/// Single source of truth for the layout `<data_path>/<slug>/<case_id>/`.
|
||||||
|
/// Use everywhere instead of inlining `.join(slug).join(case_id)` so the
|
||||||
|
/// layout can evolve in one place.
|
||||||
|
pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
||||||
|
data_path.join(slug).join(case_id)
|
||||||
|
}
|
||||||
@@ -45,13 +45,14 @@ pub async fn handle_close_case(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close only acts on cases in open/. A case in done/ is already finalized.
|
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
let case_dir = user_root.join("open").join(&case_id);
|
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||||
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
|
Some(p) => p,
|
||||||
warn!(slug = %user.slug, case_id = %case_id, "close: case not found in open/");
|
None => {
|
||||||
return Err(AppError::NotFound("Case not found".into()));
|
warn!(slug = %user.slug, case_id = %case_id, "close: case not found");
|
||||||
}
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let input_path = case_dir.join("analysis_input_v1.json");
|
let input_path = case_dir.join("analysis_input_v1.json");
|
||||||
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
|
if tokio::fs::try_exists(&input_path).await.unwrap_or(false) {
|
||||||
@@ -113,11 +114,13 @@ pub async fn handle_reanalyze_case(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
let case_dir = user_root.join("open").join(&case_id);
|
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||||
if !tokio::fs::try_exists(&case_dir).await.unwrap_or(false) {
|
Some(p) => p,
|
||||||
warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found in open/");
|
None => {
|
||||||
return Err(AppError::NotFound("Case not found".into()));
|
warn!(slug = %user.slug, case_id = %case_id, "reanalyze: case not found");
|
||||||
}
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let current_version = find_latest_document(&case_dir)
|
let current_version = find_latest_document(&case_dir)
|
||||||
.await
|
.await
|
||||||
@@ -166,8 +169,8 @@ pub async fn handle_document_view(
|
|||||||
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
.map_err(|_| AppError::BadRequest("Invalid case_id".into()))?;
|
||||||
|
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
let (case_dir, _status) = match locate_case(&user_root, &case_id).await {
|
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||||
Some(v) => v,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
warn!(slug = %user.slug, case_id = %case_id, "document view: case not found");
|
warn!(slug = %user.slug, case_id = %case_id, "document view: case not found");
|
||||||
return Err(AppError::NotFound("Case not found".into()));
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
|
|||||||
@@ -107,27 +107,13 @@ pub async fn handle_upload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Determine where to store the audio file for this case.
|
/// Determine where to store the audio file for this case.
|
||||||
/// Creates the directory if it's a new case.
|
/// Creates the directory if it's a new case. `user_root` is the per-user
|
||||||
async fn resolve_case_dir(data_dir: &Path, case_id: &str) -> Result<PathBuf, AppError> {
|
/// root (`<data_path>/<slug>/`); cases live flat directly underneath.
|
||||||
let open_dir = data_dir.join("open").join(case_id);
|
async fn resolve_case_dir(user_root: &Path, case_id: &str) -> Result<PathBuf, AppError> {
|
||||||
if open_dir.exists() {
|
let dir = user_root.join(case_id);
|
||||||
return Ok(open_dir);
|
if !dir.exists() {
|
||||||
|
tokio::fs::create_dir_all(&dir).await?;
|
||||||
|
info!(case_id = %case_id, "New case created");
|
||||||
}
|
}
|
||||||
|
Ok(dir)
|
||||||
let done_dir = data_dir.join("done").join(case_id);
|
|
||||||
if done_dir.exists() {
|
|
||||||
// Late upload for a closed case — remove .remove marker if present.
|
|
||||||
let remove_marker = done_dir.join(".remove");
|
|
||||||
if remove_marker.exists() {
|
|
||||||
tokio::fs::remove_file(&remove_marker).await?;
|
|
||||||
info!(case_id = %case_id, "Removed .remove marker due to late upload");
|
|
||||||
}
|
|
||||||
return Ok(done_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// New case — create directory under open/.
|
|
||||||
tokio::fs::create_dir_all(&open_dir).await?;
|
|
||||||
info!(case_id = %case_id, "New case created");
|
|
||||||
|
|
||||||
Ok(open_dir)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use crate::routes::web::{scan_recordings, RecordingView};
|
|||||||
struct UserCaseView {
|
struct UserCaseView {
|
||||||
case_id: String,
|
case_id: String,
|
||||||
case_id_short: String,
|
case_id_short: String,
|
||||||
status: String,
|
|
||||||
most_recent: String,
|
most_recent: String,
|
||||||
oneliner: Option<String>,
|
oneliner: Option<String>,
|
||||||
recordings: Vec<RecordingView>,
|
recordings: Vec<RecordingView>,
|
||||||
@@ -26,8 +25,7 @@ struct UserCaseView {
|
|||||||
#[template(path = "my_cases.html")]
|
#[template(path = "my_cases.html")]
|
||||||
struct MyCasesTemplate {
|
struct MyCasesTemplate {
|
||||||
slug: String,
|
slug: String,
|
||||||
open: Vec<UserCaseView>,
|
cases: Vec<UserCaseView>,
|
||||||
done: Vec<UserCaseView>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
@@ -36,7 +34,6 @@ struct CaseDetailTemplate {
|
|||||||
slug: String,
|
slug: String,
|
||||||
case_id: String,
|
case_id: String,
|
||||||
case_id_short: String,
|
case_id_short: String,
|
||||||
status: String,
|
|
||||||
oneliner: Option<String>,
|
oneliner: Option<String>,
|
||||||
recordings: Vec<RecordingView>,
|
recordings: Vec<RecordingView>,
|
||||||
can_close: bool,
|
can_close: bool,
|
||||||
@@ -46,10 +43,10 @@ struct CaseDetailTemplate {
|
|||||||
can_reanalyze: bool,
|
can_reanalyze: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Four mutually exclusive flags derived from filesystem state + config.
|
/// Flags derived from filesystem state + config. Priority order
|
||||||
/// Priority order (for UI rendering): `has_document`, then `analyzing`,
|
/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`,
|
||||||
/// then `can_close`, then `llm_missing`. If none is true, the case is
|
/// then `llm_missing`. If none is true, the case is still receiving
|
||||||
/// still receiving recordings or has zero transcripts; no action surfaced.
|
/// recordings or has zero transcripts; no action surfaced.
|
||||||
struct CaseFlags {
|
struct CaseFlags {
|
||||||
has_document: bool,
|
has_document: bool,
|
||||||
analyzing: bool,
|
analyzing: bool,
|
||||||
@@ -60,7 +57,6 @@ struct CaseFlags {
|
|||||||
|
|
||||||
async fn compute_flags(
|
async fn compute_flags(
|
||||||
case_dir: &Path,
|
case_dir: &Path,
|
||||||
status: &str,
|
|
||||||
recordings: &[RecordingView],
|
recordings: &[RecordingView],
|
||||||
llm_configured: bool,
|
llm_configured: bool,
|
||||||
is_admin: bool,
|
is_admin: bool,
|
||||||
@@ -75,7 +71,7 @@ async fn compute_flags(
|
|||||||
let all_transcribed =
|
let all_transcribed =
|
||||||
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
|
!non_failed.is_empty() && non_failed.iter().all(|r| r.transcript.is_some());
|
||||||
|
|
||||||
let transcribed_stage = status == "open" && all_transcribed && !has_document && !analyzing;
|
let transcribed_stage = !has_document && !analyzing && all_transcribed;
|
||||||
|
|
||||||
CaseFlags {
|
CaseFlags {
|
||||||
has_document,
|
has_document,
|
||||||
@@ -88,7 +84,7 @@ async fn compute_flags(
|
|||||||
|
|
||||||
/// True iff the case directory contains at least one `document_v{N}.md`.
|
/// True iff the case directory contains at least one `document_v{N}.md`.
|
||||||
/// We don't care about N here — any version means "Ausgewertet" for the UI.
|
/// We don't care about N here — any version means "Ausgewertet" for the UI.
|
||||||
async fn any_document_exists(case_dir: &Path) -> bool {
|
pub(crate) async fn any_document_exists(case_dir: &Path) -> bool {
|
||||||
let mut entries = match tokio::fs::read_dir(case_dir).await {
|
let mut entries = match tokio::fs::read_dir(case_dir).await {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => return false,
|
Err(_) => return false,
|
||||||
@@ -107,11 +103,10 @@ pub async fn handle_my_cases(
|
|||||||
user: AuthenticatedWebUser,
|
user: AuthenticatedWebUser,
|
||||||
State(config): State<Arc<Config>>,
|
State(config): State<Arc<Config>>,
|
||||||
) -> Result<Html<String>, AppError> {
|
) -> Result<Html<String>, AppError> {
|
||||||
let (open, done) = scan_user_cases(&config.data_path, &user.slug).await;
|
let cases = scan_user_cases(&config.data_path, &user.slug).await;
|
||||||
MyCasesTemplate {
|
MyCasesTemplate {
|
||||||
slug: user.slug,
|
slug: user.slug,
|
||||||
open,
|
cases,
|
||||||
done,
|
|
||||||
}
|
}
|
||||||
.render()
|
.render()
|
||||||
.map(Html)
|
.map(Html)
|
||||||
@@ -128,14 +123,14 @@ pub async fn handle_case_detail(
|
|||||||
|
|
||||||
// IDOR guard: case_dir must live under the session's user_slug.
|
// IDOR guard: case_dir must live under the session's user_slug.
|
||||||
let user_root = config.data_path.join(&user.slug);
|
let user_root = config.data_path.join(&user.slug);
|
||||||
let (case_dir, status) = match locate_case(&user_root, &case_id).await {
|
let case_dir = match locate_case(&user_root, &case_id).await {
|
||||||
Some(v) => v,
|
Some(p) => p,
|
||||||
None => {
|
None => {
|
||||||
warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)");
|
warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)");
|
||||||
return Err(AppError::NotFound("Case not found".into()));
|
return Err(AppError::NotFound("Case not found".into()));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
info!(slug = %user.slug, case_id = %case_id, status = %status, "case detail viewed");
|
info!(slug = %user.slug, case_id = %case_id, "case detail viewed");
|
||||||
|
|
||||||
let recordings = scan_recordings(&case_dir).await;
|
let recordings = scan_recordings(&case_dir).await;
|
||||||
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
let oneliner = tokio::fs::read_to_string(case_dir.join("oneliner.txt"))
|
||||||
@@ -146,7 +141,6 @@ pub async fn handle_case_detail(
|
|||||||
|
|
||||||
let flags = compute_flags(
|
let flags = compute_flags(
|
||||||
&case_dir,
|
&case_dir,
|
||||||
&status,
|
|
||||||
&recordings,
|
&recordings,
|
||||||
config.llm_configured(),
|
config.llm_configured(),
|
||||||
user.is_admin(),
|
user.is_admin(),
|
||||||
@@ -158,7 +152,6 @@ pub async fn handle_case_detail(
|
|||||||
slug: user.slug,
|
slug: user.slug,
|
||||||
case_id,
|
case_id,
|
||||||
case_id_short,
|
case_id_short,
|
||||||
status,
|
|
||||||
oneliner,
|
oneliner,
|
||||||
recordings,
|
recordings,
|
||||||
can_close: flags.can_close,
|
can_close: flags.can_close,
|
||||||
@@ -172,90 +165,76 @@ pub async fn handle_case_detail(
|
|||||||
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
.map_err(|e| AppError::Internal(format!("Template render failed: {e}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate a case directory under `<user_root>/{open|done}/<case_id>`.
|
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
|
||||||
/// Returns the path and the status bucket it was found in.
|
/// if the directory does not exist (treated as a 404 / IDOR probe by callers).
|
||||||
pub(crate) 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> {
|
||||||
for status in ["open", "done"] {
|
let p = user_root.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) {
|
Some(p)
|
||||||
return Some((p, status.to_string()));
|
} else {
|
||||||
}
|
None
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan only the given user's cases. Returns (open, done), each sorted
|
/// Scan all of the given user's cases. Returned vec is sorted by most
|
||||||
/// with most recent first.
|
/// recent recording filename (lexicographic ≈ chronological since
|
||||||
async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec<UserCaseView>, Vec<UserCaseView>) {
|
/// timestamps are ISO-8601), newest first.
|
||||||
|
async fn scan_user_cases(data_path: &Path, slug: &str) -> Vec<UserCaseView> {
|
||||||
let user_root = data_path.join(slug);
|
let user_root = data_path.join(slug);
|
||||||
let mut open = Vec::new();
|
let mut cases = Vec::new();
|
||||||
let mut done = Vec::new();
|
|
||||||
|
|
||||||
for status in ["open", "done"] {
|
let mut entries = match tokio::fs::read_dir(&user_root).await {
|
||||||
let status_dir = user_root.join(status);
|
Ok(r) => r,
|
||||||
let mut entries = match tokio::fs::read_dir(&status_dir).await {
|
Err(_) => return cases,
|
||||||
Ok(r) => r,
|
};
|
||||||
|
|
||||||
|
while let Ok(Some(case_entry)) = entries.next_entry().await {
|
||||||
|
let case_id = match case_entry.file_name().into_string() {
|
||||||
|
Ok(s) => s,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
if uuid::Uuid::parse_str(&case_id).is_err() {
|
||||||
while let Ok(Some(case_entry)) = entries.next_entry().await {
|
warn!(case_id = %case_id, "Skipping non-UUID directory");
|
||||||
let case_id = match case_entry.file_name().into_string() {
|
continue;
|
||||||
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 recordings = scan_recordings(&case_entry.path()).await;
|
|
||||||
if recordings.is_empty() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let case_path = case_entry.path();
|
|
||||||
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());
|
|
||||||
|
|
||||||
// Only the two flags the list UI needs are computed here —
|
|
||||||
// `can_close`/`llm_missing` live on the detail page.
|
|
||||||
let has_document = any_document_exists(&case_path).await;
|
|
||||||
let analyzing = !has_document
|
|
||||||
&& tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
|
|
||||||
.await
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let view = UserCaseView {
|
|
||||||
case_id,
|
|
||||||
case_id_short,
|
|
||||||
status: status.to_string(),
|
|
||||||
most_recent,
|
|
||||||
oneliner,
|
|
||||||
recordings,
|
|
||||||
analyzing,
|
|
||||||
has_document,
|
|
||||||
};
|
|
||||||
|
|
||||||
if status == "open" {
|
|
||||||
open.push(view);
|
|
||||||
} else {
|
|
||||||
done.push(view);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if !case_entry.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let case_path = case_entry.path();
|
||||||
|
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;
|
||||||
|
let analyzing = !has_document
|
||||||
|
&& tokio::fs::try_exists(case_path.join("analysis_input_v1.json"))
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
cases.push(UserCaseView {
|
||||||
|
case_id,
|
||||||
|
case_id_short,
|
||||||
|
most_recent,
|
||||||
|
oneliner,
|
||||||
|
recordings,
|
||||||
|
analyzing,
|
||||||
|
has_document,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
open.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
|
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
|
||||||
done.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
|
cases
|
||||||
(open, done)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-54
@@ -10,6 +10,7 @@ use tracing::warn;
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::routes::user_web::any_document_exists;
|
||||||
|
|
||||||
pub(crate) struct RecordingView {
|
pub(crate) struct RecordingView {
|
||||||
pub(crate) filename: String,
|
pub(crate) filename: String,
|
||||||
@@ -23,7 +24,7 @@ struct CaseView {
|
|||||||
user_slug: String,
|
user_slug: String,
|
||||||
case_id: String,
|
case_id: String,
|
||||||
case_id_short: String,
|
case_id_short: String,
|
||||||
status: String,
|
has_document: bool,
|
||||||
most_recent: String,
|
most_recent: String,
|
||||||
oneliner: Option<String>,
|
oneliner: Option<String>,
|
||||||
recordings: Vec<RecordingView>,
|
recordings: Vec<RecordingView>,
|
||||||
@@ -55,17 +56,10 @@ pub async fn handle_audio(
|
|||||||
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
|
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
|
||||||
validate_filename(&filename)?;
|
validate_filename(&filename)?;
|
||||||
|
|
||||||
let base = &config.data_path;
|
let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&filename);
|
||||||
let open_path = base.join(&user).join("open").join(&case_id).join(&filename);
|
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
|
||||||
let done_path = base.join(&user).join("done").join(&case_id).join(&filename);
|
|
||||||
|
|
||||||
let file_path = if tokio::fs::try_exists(&open_path).await.unwrap_or(false) {
|
|
||||||
open_path
|
|
||||||
} else if tokio::fs::try_exists(&done_path).await.unwrap_or(false) {
|
|
||||||
done_path
|
|
||||||
} else {
|
|
||||||
return Err(AppError::NotFound("Audio file not found".into()));
|
return Err(AppError::NotFound("Audio file not found".into()));
|
||||||
};
|
}
|
||||||
|
|
||||||
let bytes = tokio::fs::read(&file_path).await?;
|
let bytes = tokio::fs::read(&file_path).await?;
|
||||||
|
|
||||||
@@ -116,52 +110,51 @@ async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for status in ["open", "done"] {
|
let mut case_entries = match tokio::fs::read_dir(user_entry.path()).await {
|
||||||
let status_dir = user_entry.path().join(status);
|
Ok(r) => r,
|
||||||
let mut case_entries = match tokio::fs::read_dir(&status_dir).await {
|
Err(_) => continue,
|
||||||
Ok(r) => r,
|
};
|
||||||
|
|
||||||
|
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,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
if uuid::Uuid::parse_str(&case_id).is_err() {
|
||||||
while let Ok(Some(case_entry)) = case_entries.next_entry().await {
|
warn!(case_id = %case_id, "Skipping non-UUID directory");
|
||||||
let case_id = match case_entry.file_name().into_string() {
|
continue;
|
||||||
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 recordings = scan_recordings(&case_entry.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_entry.path().join("oneliner.txt"))
|
|
||||||
.await
|
|
||||||
.ok()
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty());
|
|
||||||
|
|
||||||
cases.push(CaseView {
|
|
||||||
user_slug: user_slug.clone(),
|
|
||||||
case_id,
|
|
||||||
case_id_short,
|
|
||||||
status: status.to_string(),
|
|
||||||
most_recent,
|
|
||||||
oneliner,
|
|
||||||
recordings,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
if !case_entry.path().is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let case_path = case_entry.path();
|
||||||
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use super::{TranscribeJob, TranscribeSender};
|
use super::{TranscribeJob, TranscribeSender};
|
||||||
|
|
||||||
/// Walk `data_path/*/open/*/*.m4a` and enqueue every recording that does not
|
/// Walk `data_path/*/*/*.m4a` and enqueue every recording that does not
|
||||||
/// yet have a `.transcript.txt` sibling.
|
/// yet have a `.transcript.txt` sibling.
|
||||||
///
|
///
|
||||||
/// Intended to run once at startup (spawn as a task — sends may back-pressure
|
/// Intended to run once at startup (spawn as a task — sends may back-pressure
|
||||||
@@ -29,7 +29,7 @@ pub async fn scan_and_enqueue(data_path: &Path, tx: &TranscribeSender) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walks `$data_path/<slug>/open/<case>/*.m4a` and returns `(slug, audio_path)`
|
/// Walks `$data_path/<slug>/<case>/*.m4a` and returns `(slug, audio_path)`
|
||||||
/// tuples for every recording without a `.transcript.txt` sibling. The slug
|
/// tuples for every recording without a `.transcript.txt` sibling. The slug
|
||||||
/// comes from the top-level user directory name — that's the only ownership
|
/// comes from the top-level user directory name — that's the only ownership
|
||||||
/// signal after a restart (auth context is gone).
|
/// signal after a restart (auth context is gone).
|
||||||
@@ -45,8 +45,7 @@ async fn collect_pending(data_path: &Path) -> Vec<(String, PathBuf)> {
|
|||||||
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
|
let Some(slug) = user_entry.file_name().to_str().map(str::to_owned) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let open_dir = user_entry.path().join("open");
|
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
|
||||||
let mut cases = match tokio::fs::read_dir(&open_dir).await {
|
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => continue,
|
Err(_) => continue,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ header form { margin: 0; }
|
|||||||
<div><a class="back" href="/web/cases">← Zurück</a></div>
|
<div><a class="back" href="/web/cases">← Zurück</a></div>
|
||||||
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
<form method="post" action="/web/logout"><button type="submit">Logout</button></form>
|
||||||
</header>
|
</header>
|
||||||
<h1>Fall {{ case_id_short }} <span class="status-badge {{ status }}">{{ status }}</span></h1>
|
<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 %}
|
{% match oneliner %}
|
||||||
{% when Some with (t) %}
|
{% when Some with (t) %}
|
||||||
<div class="oneliner">{{ t }}</div>
|
<div class="oneliner">{{ t }}</div>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1
|
|||||||
<p>No cases found.</p>
|
<p>No cases found.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for case in cases %}
|
{% for case in cases %}
|
||||||
<div class="case status-{{ case.status }}">
|
<div class="case {% if case.has_document %}status-done{% else %}status-open{% endif %}">
|
||||||
<h2>{{ case.user_slug }} — {{ case.case_id_short }} ({{ case.status }})</h2>
|
<h2>{{ case.user_slug }} — {{ case.case_id_short }} ({% if case.has_document %}analyzed{% else %}in progress{% endif %})</h2>
|
||||||
{% match case.oneliner %}
|
{% match case.oneliner %}
|
||||||
{% when Some with (t) %}
|
{% when Some with (t) %}
|
||||||
<div class="oneliner">{{ t }}</div>
|
<div class="oneliner">{{ t }}</div>
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ header form { margin: 0; }
|
|||||||
.case:hover { background: #f6f6f6; }
|
.case:hover { background: #f6f6f6; }
|
||||||
.case h2 { margin: 0 0 0.3em 0; font-size: 1em; }
|
.case h2 { margin: 0 0 0.3em 0; font-size: 1em; }
|
||||||
.case small { color: #666; font-family: monospace; }
|
.case small { color: #666; font-family: monospace; }
|
||||||
.status-open { border-left: 4px solid #4a90e2; }
|
.case.open { border-left: 4px solid #4a90e2; }
|
||||||
.status-done { border-left: 4px solid #7ed321; }
|
.case.done { border-left: 4px solid #7ed321; }
|
||||||
.oneliner { font-size: 1em; color: #333; margin: 0.2em 0; }
|
.oneliner { font-size: 1em; color: #333; margin: 0.2em 0; }
|
||||||
section { margin: 1.5em 0; }
|
section { margin: 1.5em 0; }
|
||||||
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; padding-bottom: 0.2em; }
|
||||||
@@ -29,31 +29,12 @@ section h2 { font-size: 1.1em; color: #555; border-bottom: 1px solid #ddd; paddi
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Offen ({{ open.len() }})</h2>
|
<h2>Fälle ({{ cases.len() }})</h2>
|
||||||
{% if open.is_empty() %}
|
{% if cases.is_empty() %}
|
||||||
<p class="empty">Keine offenen Fälle.</p>
|
<p class="empty">Keine Fälle.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% for case in open %}
|
{% for case in cases %}
|
||||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
<a class="case {% if case.has_document %}done{% else %}open{% endif %}" href="/web/cases/{{ case.case_id }}">
|
||||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}<span class="label done-doc">ausgewertet</span>{% else if case.analyzing %}<span class="label analyzing">wird analysiert</span>{% endif %}</h2>
|
|
||||||
{% match case.oneliner %}
|
|
||||||
{% when Some with (t) %}
|
|
||||||
<div class="oneliner">{{ t }}</div>
|
|
||||||
{% when None %}
|
|
||||||
{% endmatch %}
|
|
||||||
<small>{{ case.most_recent }}</small>
|
|
||||||
</a>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>Abgeschlossen ({{ done.len() }})</h2>
|
|
||||||
{% if done.is_empty() %}
|
|
||||||
<p class="empty">Keine abgeschlossenen Fälle.</p>
|
|
||||||
{% else %}
|
|
||||||
{% for case in done %}
|
|
||||||
<a class="case status-{{ case.status }}" href="/web/cases/{{ case.case_id }}">
|
|
||||||
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}<span class="label done-doc">ausgewertet</span>{% else if case.analyzing %}<span class="label analyzing">wird analysiert</span>{% endif %}</h2>
|
<h2>{{ case.case_id_short }} — {{ case.recordings.len() }} Aufnahme(n){% if case.has_document %}<span class="label done-doc">ausgewertet</span>{% else if case.analyzing %}<span class="label analyzing">wird analysiert</span>{% endif %}</h2>
|
||||||
{% match case.oneliner %}
|
{% match case.oneliner %}
|
||||||
{% when Some with (t) %}
|
{% when Some with (t) %}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ fn config_without_llm(data_path: PathBuf) -> Arc<Config> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
fn seed_case(data_path: &Path, slug: &str, case_id: &str) -> PathBuf {
|
||||||
let dir = data_path.join(slug).join("open").join(case_id);
|
let dir = data_path.join(slug).join(case_id);
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
@@ -298,7 +298,7 @@ async fn close_case_skips_blank_transcript_from_recordings() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn analyze_worker_writes_document_via_wiremock() {
|
async fn analyze_worker_writes_document_via_wiremock() {
|
||||||
let tmp = unique_tmp("w");
|
let tmp = unique_tmp("w");
|
||||||
let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111");
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
|
|
||||||
let input = json!({
|
let input = json!({
|
||||||
@@ -363,7 +363,7 @@ async fn analyze_worker_writes_document_via_wiremock() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recovery_enqueues_pending_analysis() {
|
async fn recovery_enqueues_pending_analysis() {
|
||||||
let tmp = unique_tmp("r1");
|
let tmp = unique_tmp("r1");
|
||||||
let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111");
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap();
|
std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap();
|
||||||
|
|
||||||
@@ -379,7 +379,7 @@ async fn recovery_enqueues_pending_analysis() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn recovery_skips_completed_analysis() {
|
async fn recovery_skips_completed_analysis() {
|
||||||
let tmp = unique_tmp("r2");
|
let tmp = unique_tmp("r2");
|
||||||
let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111");
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap();
|
std::fs::write(case_dir.join("analysis_input_v1.json"), "{}").unwrap();
|
||||||
std::fs::write(case_dir.join("document_v1.md"), "done").unwrap();
|
std::fs::write(case_dir.join("document_v1.md"), "done").unwrap();
|
||||||
@@ -566,7 +566,7 @@ async fn reanalyze_second_time_returns_409() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn reanalyze_worker_writes_document_v2_via_wiremock() {
|
async fn reanalyze_worker_writes_document_v2_via_wiremock() {
|
||||||
let tmp = unique_tmp("rn-w");
|
let tmp = unique_tmp("rn-w");
|
||||||
let case_dir = tmp.join("dr_a/open/11111111-1111-1111-1111-111111111111");
|
let case_dir = tmp.join("dr_a/11111111-1111-1111-1111-111111111111");
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
std::fs::write(case_dir.join("document_v1.md"), "alte Version").unwrap();
|
std::fs::write(case_dir.join("document_v1.md"), "alte Version").unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,8 @@ async fn cases_with_valid_cookie_shows_only_own_cases() {
|
|||||||
make_user("dr_b", "s"),
|
make_user("dr_b", "s"),
|
||||||
]);
|
]);
|
||||||
// Seed fixtures: dr_a has an open case, dr_b has one too.
|
// Seed fixtures: dr_a has an open case, dr_b has one too.
|
||||||
let case_a = config.data_path.join("dr_a/open/11111111-1111-1111-1111-111111111111");
|
let case_a = config.data_path.join("dr_a/11111111-1111-1111-1111-111111111111");
|
||||||
let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222");
|
let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222");
|
||||||
std::fs::create_dir_all(&case_a).unwrap();
|
std::fs::create_dir_all(&case_a).unwrap();
|
||||||
std::fs::create_dir_all(&case_b).unwrap();
|
std::fs::create_dir_all(&case_b).unwrap();
|
||||||
std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
std::fs::write(case_a.join("2026-04-14T10-00-00Z.m4a"), b"x").unwrap();
|
||||||
@@ -159,7 +159,7 @@ async fn case_detail_of_foreign_case_returns_404() {
|
|||||||
make_user("dr_a", "s"),
|
make_user("dr_a", "s"),
|
||||||
make_user("dr_b", "s"),
|
make_user("dr_b", "s"),
|
||||||
]);
|
]);
|
||||||
let case_b = config.data_path.join("dr_b/open/22222222-2222-2222-2222-222222222222");
|
let case_b = config.data_path.join("dr_b/22222222-2222-2222-2222-222222222222");
|
||||||
std::fs::create_dir_all(&case_b).unwrap();
|
std::fs::create_dir_all(&case_b).unwrap();
|
||||||
std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap();
|
std::fs::write(case_b.join("2026-04-14T11-00-00Z.m4a"), b"x").unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -212,23 +212,18 @@ async fn recovery_enqueues_only_pending_recordings() {
|
|||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let root = tmp.path();
|
let root = tmp.path();
|
||||||
|
|
||||||
// User 1, open case with two .m4a — one pending, one already transcribed.
|
// User 1, case with two .m4a — one pending, one already transcribed.
|
||||||
let case_a = root.join("dr_a/open/aaaa");
|
let case_a = root.join("dr_a/aaaa");
|
||||||
std::fs::create_dir_all(&case_a).unwrap();
|
std::fs::create_dir_all(&case_a).unwrap();
|
||||||
std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
|
std::fs::write(case_a.join("2026-04-10T10-00-00Z.m4a"), b"x").unwrap();
|
||||||
std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
|
std::fs::write(case_a.join("2026-04-11T10-00-00Z.m4a"), b"x").unwrap();
|
||||||
std::fs::write(case_a.join("2026-04-11T10-00-00Z.transcript.txt"), b"done").unwrap();
|
std::fs::write(case_a.join("2026-04-11T10-00-00Z.transcript.txt"), b"done").unwrap();
|
||||||
|
|
||||||
// User 2, open case with one pending .m4a.
|
// User 2, case with one pending .m4a.
|
||||||
let case_b = root.join("dr_b/open/bbbb");
|
let case_b = root.join("dr_b/bbbb");
|
||||||
std::fs::create_dir_all(&case_b).unwrap();
|
std::fs::create_dir_all(&case_b).unwrap();
|
||||||
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
|
std::fs::write(case_b.join("2026-04-12T10-00-00Z.m4a"), b"x").unwrap();
|
||||||
|
|
||||||
// done/ cases must be ignored (not re-transcribed).
|
|
||||||
let case_c = root.join("dr_a/done/cccc");
|
|
||||||
std::fs::create_dir_all(&case_c).unwrap();
|
|
||||||
std::fs::write(case_c.join("2026-04-09T10-00-00Z.m4a"), b"x").unwrap();
|
|
||||||
|
|
||||||
let (tx, mut rx) = transcribe::channel();
|
let (tx, mut rx) = transcribe::channel();
|
||||||
scan_and_enqueue(root, &tx).await;
|
scan_and_enqueue(root, &tx).await;
|
||||||
drop(tx); // close channel so the loop below terminates
|
drop(tx); // close channel so the loop below terminates
|
||||||
@@ -275,7 +270,7 @@ async fn worker_renames_audio_to_failed_on_whisper_error() {
|
|||||||
|
|
||||||
// Real case dir with a real m4a fixture so ffmpeg remux succeeds.
|
// Real case dir with a real m4a fixture so ffmpeg remux succeeds.
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let case_dir = tmp.path().join("dr_test/open/aaaa");
|
let case_dir = tmp.path().join("dr_test/aaaa");
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
let audio = case_dir.join("2026-04-13T10-30-00Z.m4a");
|
||||||
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
std::fs::copy(fixture("sample.m4a"), &audio).unwrap();
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ async fn upload_creates_new_case() {
|
|||||||
|
|
||||||
// Verify file on disk
|
// Verify file on disk
|
||||||
let file = data_path
|
let file = data_path
|
||||||
.join("dr_test/open")
|
.join("dr_test")
|
||||||
.join(case_id)
|
.join(case_id)
|
||||||
.join("2026-04-13T10-30-00Z.m4a");
|
.join("2026-04-13T10-30-00Z.m4a");
|
||||||
assert!(file.exists(), "Audio file should exist at {file:?}");
|
assert!(file.exists(), "Audio file should exist at {file:?}");
|
||||||
@@ -242,7 +242,7 @@ async fn upload_second_recording_same_case() {
|
|||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
|
||||||
// Both files should exist
|
// Both files should exist
|
||||||
let case_dir = data_path.join("dr_test/open").join(case_id);
|
let case_dir = data_path.join("dr_test").join(case_id);
|
||||||
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());
|
||||||
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
assert!(case_dir.join("2026-04-13T10-45-00Z.m4a").exists());
|
||||||
|
|
||||||
@@ -250,82 +250,3 @@ async fn upload_second_recording_same_case() {
|
|||||||
let _ = std::fs::remove_dir_all(&data_path);
|
let _ = std::fs::remove_dir_all(&data_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn upload_late_recording_to_done_case() {
|
|
||||||
let config = test_config();
|
|
||||||
let data_path = config.data_path.clone();
|
|
||||||
let app = doctate_server::create_router(config);
|
|
||||||
|
|
||||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
|
||||||
|
|
||||||
// Pre-create a done/ case directory (simulating a closed case).
|
|
||||||
let done_dir = data_path.join("dr_test/done").join(case_id);
|
|
||||||
std::fs::create_dir_all(&done_dir).unwrap();
|
|
||||||
|
|
||||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T11:00:00Z", b"late recording");
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/upload")
|
|
||||||
.header("X-API-Key", "test-key-123")
|
|
||||||
.header(
|
|
||||||
"Content-Type",
|
|
||||||
format!("multipart/form-data; boundary={boundary}"),
|
|
||||||
)
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
// File should be in done/, not open/
|
|
||||||
assert!(done_dir.join("2026-04-13T11-00-00Z.m4a").exists());
|
|
||||||
assert!(!data_path.join("dr_test/open").join(case_id).exists());
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&data_path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn upload_removes_remove_marker_on_late_upload() {
|
|
||||||
let config = test_config();
|
|
||||||
let data_path = config.data_path.clone();
|
|
||||||
let app = doctate_server::create_router(config);
|
|
||||||
|
|
||||||
let case_id = "880e8400-e29b-41d4-a716-446655440000";
|
|
||||||
|
|
||||||
// Pre-create a done/ case with .remove marker.
|
|
||||||
let done_dir = data_path.join("dr_test/done").join(case_id);
|
|
||||||
std::fs::create_dir_all(&done_dir).unwrap();
|
|
||||||
std::fs::write(done_dir.join(".remove"), b"").unwrap();
|
|
||||||
assert!(done_dir.join(".remove").exists());
|
|
||||||
|
|
||||||
let (boundary, body) = multipart_body(case_id, "2026-04-13T12:00:00Z", b"surprise recording");
|
|
||||||
let response = app
|
|
||||||
.oneshot(
|
|
||||||
Request::builder()
|
|
||||||
.method("POST")
|
|
||||||
.uri("/api/upload")
|
|
||||||
.header("X-API-Key", "test-key-123")
|
|
||||||
.header(
|
|
||||||
"Content-Type",
|
|
||||||
format!("multipart/form-data; boundary={boundary}"),
|
|
||||||
)
|
|
||||||
.body(Body::from(body))
|
|
||||||
.unwrap(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
|
||||||
|
|
||||||
// .remove marker should be gone, audio should be there
|
|
||||||
assert!(!done_dir.join(".remove").exists());
|
|
||||||
assert!(done_dir.join("2026-04-13T12-00-00Z.m4a").exists());
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
let _ = std::fs::remove_dir_all(&data_path);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ async fn web_index_shows_cases() {
|
|||||||
let case1 = "550e8400-e29b-41d4-a716-446655440000";
|
let case1 = "550e8400-e29b-41d4-a716-446655440000";
|
||||||
let case2 = "660e8400-e29b-41d4-a716-446655440000";
|
let case2 = "660e8400-e29b-41d4-a716-446655440000";
|
||||||
|
|
||||||
let open_case = data_path.join("dr_test/open").join(case1);
|
let open_case = data_path.join("dr_test").join(case1);
|
||||||
std::fs::create_dir_all(&open_case).unwrap();
|
std::fs::create_dir_all(&open_case).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
open_case.join("2026-04-13T10-30-00Z.m4a"),
|
open_case.join("2026-04-13T10-30-00Z.m4a"),
|
||||||
@@ -71,7 +71,7 @@ async fn web_index_shows_cases() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let done_case = data_path.join("dr_test/done").join(case2);
|
let done_case = data_path.join("dr_test").join(case2);
|
||||||
std::fs::create_dir_all(&done_case).unwrap();
|
std::fs::create_dir_all(&done_case).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
done_case.join("2026-04-12T09-00-00Z.m4a"),
|
done_case.join("2026-04-12T09-00-00Z.m4a"),
|
||||||
@@ -125,7 +125,7 @@ async fn web_audio_serves_file() {
|
|||||||
let data_path = config.data_path.clone();
|
let data_path = config.data_path.clone();
|
||||||
|
|
||||||
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
let case_id = "770e8400-e29b-41d4-a716-446655440000";
|
||||||
let case_dir = data_path.join("dr_test/open").join(case_id);
|
let case_dir = data_path.join("dr_test").join(case_id);
|
||||||
std::fs::create_dir_all(&case_dir).unwrap();
|
std::fs::create_dir_all(&case_dir).unwrap();
|
||||||
|
|
||||||
let audio_bytes = b"fake audio content for test";
|
let audio_bytes = b"fake audio content for test";
|
||||||
|
|||||||
Reference in New Issue
Block a user