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