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:
2026-04-15 22:15:13 +02:00
parent 70eed20909
commit 11eb645f3f
17 changed files with 190 additions and 324 deletions
+2 -3
View File
@@ -4,7 +4,7 @@ use tracing::{info, warn};
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.
///
/// 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 {
let open_dir = user_entry.path().join("open");
let mut cases = match tokio::fs::read_dir(&open_dir).await {
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
Ok(r) => r,
Err(_) => continue,
};
+1 -2
View File
@@ -59,8 +59,7 @@ where
.ok_or(AppError::Unauthorized)?;
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.join("done")).await?;
tokio::fs::create_dir_all(&data_dir).await?;
Ok(Self {
slug: slug.clone(),
+1
View File
@@ -3,6 +3,7 @@ pub mod auth;
pub mod config;
pub mod error;
pub mod models;
pub mod paths;
pub mod routes;
pub mod transcribe;
pub mod web_session;
+10
View File
@@ -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)
}
+16 -13
View File
@@ -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 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 case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
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");
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 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, "reanalyze: case not found in open/");
return Err(AppError::NotFound("Case not found".into()));
}
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
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)
.await
@@ -166,8 +169,8 @@ pub async fn handle_document_view(
.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,
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "document view: case not found");
return Err(AppError::NotFound("Case not found".into()));
+8 -22
View File
@@ -107,27 +107,13 @@ pub async fn handle_upload(
}
/// Determine where to store the audio file for this case.
/// Creates the directory if it's a new case.
async fn resolve_case_dir(data_dir: &Path, case_id: &str) -> Result<PathBuf, AppError> {
let open_dir = data_dir.join("open").join(case_id);
if open_dir.exists() {
return Ok(open_dir);
/// Creates the directory if it's a new case. `user_root` is the per-user
/// root (`<data_path>/<slug>/`); cases live flat directly underneath.
async fn resolve_case_dir(user_root: &Path, case_id: &str) -> Result<PathBuf, AppError> {
let dir = user_root.join(case_id);
if !dir.exists() {
tokio::fs::create_dir_all(&dir).await?;
info!(case_id = %case_id, "New case created");
}
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)
Ok(dir)
}
+74 -95
View File
@@ -14,7 +14,6 @@ use crate::routes::web::{scan_recordings, RecordingView};
struct UserCaseView {
case_id: String,
case_id_short: String,
status: String,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
@@ -26,8 +25,7 @@ struct UserCaseView {
#[template(path = "my_cases.html")]
struct MyCasesTemplate {
slug: String,
open: Vec<UserCaseView>,
done: Vec<UserCaseView>,
cases: Vec<UserCaseView>,
}
#[derive(Template)]
@@ -36,7 +34,6 @@ struct CaseDetailTemplate {
slug: String,
case_id: String,
case_id_short: String,
status: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
can_close: bool,
@@ -46,10 +43,10 @@ struct CaseDetailTemplate {
can_reanalyze: bool,
}
/// Four mutually exclusive flags derived from filesystem state + config.
/// Priority order (for UI rendering): `has_document`, then `analyzing`,
/// then `can_close`, then `llm_missing`. If none is true, the case is
/// still receiving recordings or has zero transcripts; no action surfaced.
/// Flags derived from filesystem state + config. Priority order
/// (for UI rendering): `has_document`, then `analyzing`, then `can_close`,
/// then `llm_missing`. If none is true, the case is still receiving
/// recordings or has zero transcripts; no action surfaced.
struct CaseFlags {
has_document: bool,
analyzing: bool,
@@ -60,7 +57,6 @@ struct CaseFlags {
async fn compute_flags(
case_dir: &Path,
status: &str,
recordings: &[RecordingView],
llm_configured: bool,
is_admin: bool,
@@ -75,7 +71,7 @@ async fn compute_flags(
let all_transcribed =
!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 {
has_document,
@@ -88,7 +84,7 @@ async fn compute_flags(
/// 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.
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 {
Ok(r) => r,
Err(_) => return false,
@@ -107,11 +103,10 @@ pub async fn handle_my_cases(
user: AuthenticatedWebUser,
State(config): State<Arc<Config>>,
) -> 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 {
slug: user.slug,
open,
done,
cases,
}
.render()
.map(Html)
@@ -128,14 +123,14 @@ pub async fn handle_case_detail(
// IDOR guard: case_dir must live under the session's user_slug.
let user_root = config.data_path.join(&user.slug);
let (case_dir, status) = match locate_case(&user_root, &case_id).await {
Some(v) => v,
let case_dir = match locate_case(&user_root, &case_id).await {
Some(p) => p,
None => {
warn!(slug = %user.slug, case_id = %case_id, "case detail: not found (possible IDOR probe)");
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 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(
&case_dir,
&status,
&recordings,
config.llm_configured(),
user.is_admin(),
@@ -158,7 +152,6 @@ pub async fn handle_case_detail(
slug: user.slug,
case_id,
case_id_short,
status,
oneliner,
recordings,
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}")))
}
/// Locate a case directory under `<user_root>/{open|done}/<case_id>`.
/// Returns the path and the status bucket it was found in.
pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option<(std::path::PathBuf, String)> {
for status in ["open", "done"] {
let p = user_root.join(status).join(case_id);
if tokio::fs::try_exists(&p).await.unwrap_or(false) {
return Some((p, status.to_string()));
}
/// Locate a case directory under `<user_root>/<case_id>/`. Returns `None`
/// 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> {
let p = user_root.join(case_id);
if tokio::fs::try_exists(&p).await.unwrap_or(false) {
Some(p)
} else {
None
}
None
}
/// Scan only the given user's cases. Returns (open, done), each sorted
/// with most recent first.
async fn scan_user_cases(data_path: &Path, slug: &str) -> (Vec<UserCaseView>, Vec<UserCaseView>) {
/// Scan all of the given user's cases. Returned vec is sorted by most
/// recent recording filename (lexicographic ≈ chronological since
/// 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 mut open = Vec::new();
let mut done = Vec::new();
let mut cases = Vec::new();
for status in ["open", "done"] {
let status_dir = user_root.join(status);
let mut entries = match tokio::fs::read_dir(&status_dir).await {
Ok(r) => r,
let mut entries = match tokio::fs::read_dir(&user_root).await {
Ok(r) => r,
Err(_) => return cases,
};
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,
};
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,
};
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 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();
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));
done.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
(open, done)
cases.sort_by(|a, b| b.most_recent.cmp(&a.most_recent));
cases
}
+47 -54
View File
@@ -10,6 +10,7 @@ use tracing::warn;
use crate::config::Config;
use crate::error::AppError;
use crate::routes::user_web::any_document_exists;
pub(crate) struct RecordingView {
pub(crate) filename: String,
@@ -23,7 +24,7 @@ struct CaseView {
user_slug: String,
case_id: String,
case_id_short: String,
status: String,
has_document: bool,
most_recent: String,
oneliner: Option<String>,
recordings: Vec<RecordingView>,
@@ -55,17 +56,10 @@ pub async fn handle_audio(
.map_err(|_| AppError::BadRequest("Invalid case_id (not a UUID)".into()))?;
validate_filename(&filename)?;
let base = &config.data_path;
let open_path = base.join(&user).join("open").join(&case_id).join(&filename);
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 {
let file_path = crate::paths::case_dir(&config.data_path, &user, &case_id).join(&filename);
if !tokio::fs::try_exists(&file_path).await.unwrap_or(false) {
return Err(AppError::NotFound("Audio file not found".into()));
};
}
let bytes = tokio::fs::read(&file_path).await?;
@@ -116,52 +110,51 @@ async fn scan_cases(data_path: &Path) -> Vec<CaseView> {
continue;
}
for status in ["open", "done"] {
let status_dir = user_entry.path().join(status);
let mut case_entries = match tokio::fs::read_dir(&status_dir).await {
Ok(r) => r,
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,
};
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 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 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();
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,
});
}
}
+3 -4
View File
@@ -4,7 +4,7 @@ use tracing::{info, warn};
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.
///
/// 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
/// comes from the top-level user directory name — that's the only ownership
/// 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 {
continue;
};
let open_dir = user_entry.path().join("open");
let mut cases = match tokio::fs::read_dir(&open_dir).await {
let mut cases = match tokio::fs::read_dir(user_entry.path()).await {
Ok(r) => r,
Err(_) => continue,
};