From 9410d6daaadbbdfe6a1db47e9a95b0fad4a02787 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 21 Apr 2026 10:36:55 +0200 Subject: [PATCH] refactor: rename soft-delete marker from .deleted to .closed Internal rename: DELETE_MARKER -> CLOSE_MARKER, DeleteMarker -> CloseMarker, is_deleted -> is_closed. The batch UUID is dropped; undo now groups cases by their exact closed_at timestamp, which bulk-close writes identically across its selection while per-case close writes a unique stamp. Behavior unchanged. No migration (project is pre-production); legacy .deleted files in tmpdata were removed manually. --- server/src/analyze/auto_trigger.rs | 2 +- server/src/analyze/recovery.rs | 2 +- server/src/paths.rs | 44 +++++++-------- server/src/routes/bulk.rs | 27 +++------ server/src/routes/case_actions.rs | 89 ++++++++++++++---------------- server/src/routes/oneliners.rs | 2 +- server/src/routes/upload.rs | 10 ++-- server/src/routes/user_web.rs | 12 ++-- server/src/routes/web.rs | 2 +- server/src/transcribe/recovery.rs | 11 ++-- server/tests/analyze_test.rs | 40 +++++++------- server/tests/oneliners_api_test.rs | 2 +- server/tests/upload_test.rs | 33 ++++++----- 13 files changed, 131 insertions(+), 145 deletions(-) diff --git a/server/src/analyze/auto_trigger.rs b/server/src/analyze/auto_trigger.rs index 556adcf..84c81f3 100644 --- a/server/src/analyze/auto_trigger.rs +++ b/server/src/analyze/auto_trigger.rs @@ -175,7 +175,7 @@ pub async fn try_enqueue_all_for_user( if !case_path.is_dir() { continue; } - if crate::paths::is_deleted(&case_path).await { + if crate::paths::is_closed(&case_path).await { continue; } try_enqueue(&case_path, tx, config, events_tx).await; diff --git a/server/src/analyze/recovery.rs b/server/src/analyze/recovery.rs index 7ec4675..d0b262b 100644 --- a/server/src/analyze/recovery.rs +++ b/server/src/analyze/recovery.rs @@ -27,7 +27,7 @@ pub async fn enqueue_pending_for_user(user_root: &Path, tx: &AnalyzeSender) -> u let mut sent = 0; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); - if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await { + if !case_dir.is_dir() || crate::paths::is_closed(&case_dir).await { continue; } if !case_dir.join(ANALYSIS_INPUT_FILE).exists() { diff --git a/server/src/paths.rs b/server/src/paths.rs index d056334..85a50fd 100644 --- a/server/src/paths.rs +++ b/server/src/paths.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use doctate_common::oneliners::{ONELINER_FILENAME, OnelinerState}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; -use uuid::Uuid; /// Absolute on-disk location of a case directory. /// @@ -14,39 +13,40 @@ pub fn case_dir(data_path: &Path, slug: &str, case_id: &str) -> PathBuf { data_path.join(slug).join(case_id) } -/// Filename of the soft-delete marker inside a case directory. -pub const DELETE_MARKER: &str = ".deleted"; +/// Filename of the close marker inside a case directory. A case with +/// this marker is "closed": hidden from the default listing, reversible +/// via a new upload or the explicit reopen action, and eligible for +/// lazy auto-purge after `auto_delete_days`. +pub const CLOSE_MARKER: &str = ".closed"; -/// Soft-delete marker payload. One file per deleted case; all cases removed -/// in the same user click share the same `batch` UUID. `deleted_at` is an -/// RFC3339 timestamp — used to find the most recent batch for "Undo last -/// delete". +/// Close-marker payload. `closed_at` is an RFC3339 timestamp and the +/// authoritative input for purge eligibility (`now - closed_at > auto_delete_days`). #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DeleteMarker { - pub batch: Uuid, - pub deleted_at: String, +pub struct CloseMarker { + pub closed_at: String, } -/// True iff the case directory contains a `.deleted` marker file. -pub async fn is_deleted(case_dir: &Path) -> bool { - tokio::fs::try_exists(case_dir.join(DELETE_MARKER)) +/// True iff the case directory contains a `.closed` marker file. +pub async fn is_closed(case_dir: &Path) -> bool { + tokio::fs::try_exists(case_dir.join(CLOSE_MARKER)) .await .unwrap_or(false) } -/// Read and parse the `.deleted` marker. Returns `None` if absent or malformed -/// (treated identically — a malformed marker still means "deleted" via -/// `is_deleted`, but cannot participate in batch-undo). -pub async fn read_delete_marker(case_dir: &Path) -> Option { - let bytes = tokio::fs::read(case_dir.join(DELETE_MARKER)).await.ok()?; +/// Read and parse the `.closed` marker. Returns `None` if absent or +/// malformed (treated identically — a malformed marker still means +/// "closed" via `is_closed`, but cannot participate in timestamp-based +/// grouping or purge checks). +pub async fn read_close_marker(case_dir: &Path) -> Option { + let bytes = tokio::fs::read(case_dir.join(CLOSE_MARKER)).await.ok()?; serde_json::from_slice(&bytes).ok() } -/// Write the `.deleted` marker as JSON. Overwrites any existing marker. -pub async fn write_delete_marker(case_dir: &Path, marker: &DeleteMarker) -> std::io::Result<()> { +/// Write the `.closed` marker as JSON. Overwrites any existing marker. +pub async fn write_close_marker(case_dir: &Path, marker: &CloseMarker) -> std::io::Result<()> { let bytes = serde_json::to_vec(marker) - .map_err(|e| std::io::Error::other(format!("serialize delete marker: {e}")))?; - tokio::fs::write(case_dir.join(DELETE_MARKER), bytes).await + .map_err(|e| std::io::Error::other(format!("serialize close marker: {e}")))?; + tokio::fs::write(case_dir.join(CLOSE_MARKER), bytes).await } /// Read the persisted oneliner state for a case. diff --git a/server/src/routes/bulk.rs b/server/src/routes/bulk.rs index df9cd01..58cf3d1 100644 --- a/server/src/routes/bulk.rs +++ b/server/src/routes/bulk.rs @@ -3,9 +3,8 @@ use std::sync::Arc; use axum::extract::State; use axum::response::Redirect; use axum_extra::extract::Form; +use doctate_common::timestamp::now_rfc3339; use serde::Deserialize; -use time::OffsetDateTime; -use time::format_description::well_known::Rfc3339; use tracing::{info, warn}; use crate::analyze::{ANALYSIS_INPUT_FILE, AnalyzeJob, AnalyzeSender, DOCUMENT_FILE}; @@ -13,7 +12,7 @@ use crate::auth::AuthenticatedWebUser; use crate::config::Config; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; -use crate::paths::{DeleteMarker, write_delete_marker}; +use crate::paths::{CloseMarker, write_close_marker}; use crate::routes::case_actions::{ build_analysis_input, reset_case_artefacts, write_input_create_new, }; @@ -140,16 +139,9 @@ async fn bulk_delete( user_root: &std::path::Path, case_ids: &[String], ) { - // One batch UUID + one timestamp shared across the entire bulk action, - // so undo can restore exactly this group. - let batch = uuid::Uuid::new_v4(); - let deleted_at = match OffsetDateTime::now_utc().format(&Rfc3339) { - Ok(s) => s, - Err(e) => { - warn!(slug = %slug, error = %e, "bulk-delete: failed to format timestamp"); - return; - } - }; + // Shared `closed_at` across the whole bulk action: identical timestamp + // groups the cases so a subsequent undo can restore exactly this click. + let closed_at = now_rfc3339(); let mut ok = 0usize; let mut skipped = 0usize; for case_id in case_ids { @@ -163,11 +155,10 @@ async fn bulk_delete( skipped += 1; continue; }; - let marker = DeleteMarker { - batch, - deleted_at: deleted_at.clone(), + let marker = CloseMarker { + closed_at: closed_at.clone(), }; - if let Err(e) = write_delete_marker(&case_dir, &marker).await { + if let Err(e) = write_close_marker(&case_dir, &marker).await { warn!(slug = %slug, case_id = %case_id, error = %e, "bulk-delete: write marker failed"); skipped += 1; continue; @@ -175,7 +166,7 @@ async fn bulk_delete( events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted); ok += 1; } - info!(slug = %slug, batch = %batch, ok, skipped, "bulk-delete completed"); + info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-delete completed"); } async fn bulk_reset( diff --git a/server/src/routes/case_actions.rs b/server/src/routes/case_actions.rs index a0eb207..8719652 100644 --- a/server/src/routes/case_actions.rs +++ b/server/src/routes/case_actions.rs @@ -22,7 +22,7 @@ use crate::case_id::CaseIdPath; use crate::config::Config; use crate::error::AppError; use crate::events::{self, CaseEventKind, EventSender}; -use crate::paths::{DELETE_MARKER, DeleteMarker, read_delete_marker, write_delete_marker}; +use crate::paths::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker}; use crate::routes::user_web::locate_case_or_404; use crate::routes::web::validate_filename; @@ -278,10 +278,10 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()> /// POST /web/cases/{case_id}/delete /// -/// Soft-delete: writes a `.deleted` JSON marker into the case directory. +/// Close the case: writes a `.closed` JSON marker into the case directory. /// The case immediately disappears from listings and detail views (404). /// Reversible via `handle_undo_delete` while the marker still has the -/// most recent `deleted_at` timestamp among the user's deleted cases. +/// most recent `closed_at` timestamp among the user's closed cases. pub async fn handle_delete_case( user: AuthenticatedWebUser, State(config): State>, @@ -291,17 +291,16 @@ pub async fn handle_delete_case( let user_root = config.data_path.join(&user.slug); let case_dir = locate_case_or_404(&user_root, &case_id, &user.slug, "delete").await?; - let marker = DeleteMarker { - batch: uuid::Uuid::new_v4(), - deleted_at: now_rfc3339(), + let marker = CloseMarker { + closed_at: now_rfc3339(), }; - write_delete_marker(&case_dir, &marker).await?; + write_close_marker(&case_dir, &marker).await?; info!( slug = %user.slug, case_id = %case_id, - batch = %marker.batch, - "case soft-deleted" + closed_at = %marker.closed_at, + "case closed" ); events::emit( @@ -351,10 +350,11 @@ pub async fn handle_reset_case( /// POST /web/cases/undo-delete /// -/// Restore every case in the most recent delete batch by removing its -/// `.deleted` marker. "Most recent" = highest `deleted_at` across all -/// markers under the user's data directory; ties broken by `batch`-UUID -/// lexicographic order. No-op if no markers exist. +/// Restore every case in the most recent close "group" by removing its +/// `.closed` marker. A group is defined as all cases sharing the exact +/// same `closed_at` RFC3339 timestamp — bulk-close writes one shared +/// timestamp across its selection, single-case close writes its own. +/// No-op if no markers exist. pub async fn handle_undo_delete( user: AuthenticatedWebUser, State(config): State>, @@ -362,23 +362,22 @@ pub async fn handle_undo_delete( ) -> Result { let user_root = config.data_path.join(&user.slug); - let latest = latest_delete_batch(&user_root).await; - let Some((batch, _)) = latest else { + let Some(group_ts) = latest_close_timestamp(&user_root).await else { info!(slug = %user.slug, "undo-delete: nothing to undo"); return Ok(Redirect::to("/web/cases")); }; - let restored = restore_batch(&user_root, batch, &events_tx, &user.slug).await; - info!(slug = %user.slug, batch = %batch, restored, "undo-delete completed"); + let restored = restore_close_group(&user_root, &group_ts, &events_tx, &user.slug).await; + info!(slug = %user.slug, closed_at = %group_ts, restored, "undo-delete completed"); Ok(Redirect::to("/web/cases")) } -/// Summarize the most recent delete batch under `user_root`: returns -/// `(batch_uuid, count)` where count is the number of cases sharing that -/// batch. `None` if no `.deleted` markers exist. Single directory scan. -pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uuid, usize)> { - let (batch, _) = latest_delete_batch(user_root).await?; +/// Summarize the most recent close group under `user_root`: returns +/// `(closed_at, count)` where `count` is the number of cases sharing that +/// exact timestamp. `None` if no `.closed` markers exist. Single dir scan. +pub(crate) async fn summarize_latest_close_group(user_root: &Path) -> Option<(String, usize)> { + let group_ts = latest_close_timestamp(user_root).await?; let mut count = 0usize; let mut entries = tokio::fs::read_dir(user_root).await.ok()?; while let Ok(Some(entry)) = entries.next_entry().await { @@ -386,48 +385,44 @@ pub(crate) async fn summarize_latest_batch(user_root: &Path) -> Option<(uuid::Uu if !case_path.is_dir() { continue; } - if let Some(marker) = read_delete_marker(&case_path).await - && marker.batch == batch + if let Some(marker) = read_close_marker(&case_path).await + && marker.closed_at == group_ts { count += 1; } } - Some((batch, count)) + Some((group_ts, count)) } -/// Scan the user's data dir, return the `(batch, deleted_at)` of the -/// most-recently-deleted case across all markers. `None` if no markers. -async fn latest_delete_batch(user_root: &Path) -> Option<(uuid::Uuid, String)> { +/// Scan the user's data dir and return the maximum `closed_at` across +/// all `.closed` markers. `None` if no markers exist. Used to identify +/// the "most-recently-closed" group for undo. +async fn latest_close_timestamp(user_root: &Path) -> Option { let mut entries = tokio::fs::read_dir(user_root).await.ok()?; - let mut best: Option<(uuid::Uuid, String)> = None; + let mut best: Option = None; while let Ok(Some(entry)) = entries.next_entry().await { if !entry.path().is_dir() { continue; } - let Some(marker) = read_delete_marker(&entry.path()).await else { + let Some(marker) = read_close_marker(&entry.path()).await else { continue; }; - let candidate = (marker.batch, marker.deleted_at); best = match best { - None => Some(candidate), - // Order: deleted_at desc, then batch desc as tie-breaker. - Some(ref cur) - if candidate.1 > cur.1 || (candidate.1 == cur.1 && candidate.0 > cur.0) => - { - Some(candidate) - } + None => Some(marker.closed_at), + Some(cur) if marker.closed_at > cur => Some(marker.closed_at), other => other, }; } best } -/// Remove `.deleted` markers from every case whose marker carries `batch`. -/// Returns the number of restored cases. Emits a `CaseRestored` event per -/// case so subscribed browsers can update their listings. -async fn restore_batch( +/// Remove `.closed` markers from every case whose `closed_at` equals +/// `group_ts`. Returns the number of restored cases. Emits a +/// `CaseRestored` event per case so subscribed browsers can update +/// their listings. +async fn restore_close_group( user_root: &Path, - batch: uuid::Uuid, + group_ts: &str, events_tx: &EventSender, user_slug: &str, ) -> usize { @@ -441,14 +436,14 @@ async fn restore_batch( if !case_path.is_dir() { continue; } - let Some(marker) = read_delete_marker(&case_path).await else { + let Some(marker) = read_close_marker(&case_path).await else { continue; }; - if marker.batch != batch { + if marker.closed_at != group_ts { continue; } - if let Err(e) = tokio::fs::remove_file(case_path.join(DELETE_MARKER)).await { - warn!(case_dir = ?case_path, error = %e, "failed to remove .deleted marker"); + if let Err(e) = tokio::fs::remove_file(case_path.join(CLOSE_MARKER)).await { + warn!(case_dir = ?case_path, error = %e, "failed to remove .closed marker"); continue; } events::emit( diff --git a/server/src/routes/oneliners.rs b/server/src/routes/oneliners.rs index 1979ede..270446c 100644 --- a/server/src/routes/oneliners.rs +++ b/server/src/routes/oneliners.rs @@ -140,7 +140,7 @@ async fn scan_cases(user_data_dir: &Path, since: OffsetDateTime) -> ScanResult { if uuid::Uuid::parse_str(case_id).is_err() { continue; } - if crate::paths::is_deleted(&case_dir).await { + if crate::paths::is_closed(&case_dir).await { continue; } diff --git a/server/src/routes/upload.rs b/server/src/routes/upload.rs index 6ec9e25..dc56307 100644 --- a/server/src/routes/upload.rs +++ b/server/src/routes/upload.rs @@ -74,18 +74,18 @@ pub async fn handle_upload( // Find or create the case directory. let case_dir = resolve_case_dir(&user.data_dir, &case_id).await?; - // Auto-reopen soft-deleted cases: a new upload clears the `.deleted` + // Auto-reopen closed cases: a new upload clears the `.closed` // marker so the case reappears in /api/oneliners and the web UI. - // Hard-deleted cases (directory already gone) are handled transparently + // Hard-purged cases (directory already gone) are handled transparently // by `resolve_case_dir` above — nothing to clear. No explicit ETag // bump is needed: the subsequent m4a write changes `last_recording_at`, // which flows into the `/api/oneliners` fingerprint automatically. - if crate::paths::is_deleted(&case_dir).await { - tokio::fs::remove_file(case_dir.join(crate::paths::DELETE_MARKER)).await?; + if crate::paths::is_closed(&case_dir).await { + tokio::fs::remove_file(case_dir.join(crate::paths::CLOSE_MARKER)).await?; info!( user = %user.slug, case_id = %case_id, - "Reopened soft-deleted case via new upload" + "Reopened closed case via new upload" ); events::emit( &events_tx, diff --git a/server/src/routes/user_web.rs b/server/src/routes/user_web.rs index 18878ed..2f24b2f 100644 --- a/server/src/routes/user_web.rs +++ b/server/src/routes/user_web.rs @@ -319,7 +319,7 @@ pub async fn handle_my_cases( let cases = scan_user_cases(&config, &user.slug, busy).await; let total = cases.len(); let groups = group_by_utc_date(cases); - let undo_count = crate::routes::case_actions::summarize_latest_batch(&user_root) + let undo_count = crate::routes::case_actions::summarize_latest_close_group(&user_root) .await .map(|(_, n)| n) .unwrap_or(0); @@ -478,14 +478,14 @@ pub async fn handle_case_recordings( } /// Locate a case directory under `//`. Returns `None` -/// if the directory does not exist OR is soft-deleted (`.deleted` marker +/// if the directory does not exist OR is closed (`.closed` marker /// present). Both are treated as 404 / IDOR probe by callers. pub(crate) async fn locate_case(user_root: &Path, case_id: &str) -> Option { let p = user_root.join(case_id); if !tokio::fs::try_exists(&p).await.unwrap_or(false) { return None; } - if crate::paths::is_deleted(&p).await { + if crate::paths::is_closed(&p).await { return None; } Some(p) @@ -519,8 +519,8 @@ pub(crate) async fn locate_case_or_404( /// 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. Soft-deleted cases (with -/// `.deleted` marker) are excluded. +/// timestamps are ISO-8601), newest first. Closed cases (with +/// `.closed` marker) are excluded. async fn scan_user_cases(config: &Config, slug: &str, worker_busy: bool) -> Vec { let user_root = config.data_path.join(slug); let llm_configured = config.llm_configured(); @@ -560,7 +560,7 @@ async fn filter_valid_case_dir(entry: tokio::fs::DirEntry) -> Option<(String, st if !case_path.is_dir() { return None; } - if crate::paths::is_deleted(&case_path).await { + if crate::paths::is_closed(&case_path).await { return None; } Some((case_id, case_path)) diff --git a/server/src/routes/web.rs b/server/src/routes/web.rs index ac2a2b8..5e44995 100644 --- a/server/src/routes/web.rs +++ b/server/src/routes/web.rs @@ -41,7 +41,7 @@ pub async fn handle_audio( validate_filename(&filename)?; let case_path = crate::paths::case_dir(&config.data_path, &user, &case_id); - if crate::paths::is_deleted(&case_path).await { + if crate::paths::is_closed(&case_path).await { return Err(AppError::NotFound("Audio file not found".into())); } let file_path = case_path.join(&filename); diff --git a/server/src/transcribe/recovery.rs b/server/src/transcribe/recovery.rs index 7fe3701..dd33480 100644 --- a/server/src/transcribe/recovery.rs +++ b/server/src/transcribe/recovery.rs @@ -46,7 +46,7 @@ pub async fn enqueue_pending_for_user( let mut pending: Vec = Vec::new(); while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); - if !case_dir.is_dir() || crate::paths::is_deleted(&case_dir).await { + if !case_dir.is_dir() || crate::paths::is_closed(&case_dir).await { continue; } let Ok(mut files) = tokio::fs::read_dir(&case_dir).await else { @@ -128,7 +128,7 @@ pub(crate) async fn cases_needing_oneliner_in(user_root: &Path) -> Vec }; while let Ok(Some(case_entry)) = cases.next_entry().await { let case_dir = case_entry.path(); - if !case_dir.is_dir() || paths::is_deleted(&case_dir).await { + if !case_dir.is_dir() || paths::is_closed(&case_dir).await { continue; } let (state, _) = paths::read_oneliner_state(&case_dir).await; @@ -308,15 +308,16 @@ mod tests { } #[tokio::test] - async fn cases_needing_oneliner_skips_deleted_cases() { + async fn cases_needing_oneliner_skips_closed_cases() { let data = tempdir().unwrap(); let case = data.path().join("user").join("case1"); tokio::fs::create_dir_all(&case).await.unwrap(); tokio::fs::write(case.join("2026-04-16T10-00-00Z.transcript.txt"), "content") .await .unwrap(); - // Empty JSON object is a valid delete marker (see paths::is_deleted). - tokio::fs::write(case.join(".deleted"), "{}").await.unwrap(); + // Empty JSON object is treated as a valid close marker + // (see paths::is_closed — existence of the file is enough). + tokio::fs::write(case.join(".closed"), "{}").await.unwrap(); assert!(cases_needing_oneliner(data.path()).await.is_empty()); } diff --git a/server/tests/analyze_test.rs b/server/tests/analyze_test.rs index 514f552..24009b2 100644 --- a/server/tests/analyze_test.rs +++ b/server/tests/analyze_test.rs @@ -628,14 +628,13 @@ async fn delete_writes_marker_and_redirects() { .unwrap(), "/web/cases" ); - let marker = case_dir.join(".deleted"); - assert!(marker.exists(), ".deleted marker missing"); + let marker = case_dir.join(".closed"); + assert!(marker.exists(), ".closed marker missing"); let raw = std::fs::read_to_string(&marker).unwrap(); let parsed: Value = serde_json::from_str(&raw).unwrap(); - assert!(parsed["batch"].is_string(), "batch must be a UUID string"); assert!( - parsed["deleted_at"].is_string(), - "deleted_at must be a string" + parsed["closed_at"].is_string(), + "closed_at must be a string" ); } @@ -670,7 +669,7 @@ async fn deleted_case_returns_404_on_detail() { } #[tokio::test] -async fn undo_delete_restores_latest_batch_only() { +async fn undo_delete_restores_latest_group_only() { let config = config_with_llm(unique_tmp("undo-1"), "http://unused".into()); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; @@ -682,15 +681,16 @@ async fn undo_delete_restores_latest_batch_only() { let app = doctate_server::create_router(config); let cookie = login(app.clone(), "dr_a").await; - // Two separate delete clicks → two distinct batches. + // Two separate close clicks → two distinct close-groups (distinct + // `closed_at` timestamps). let _ = app .clone() .oneshot(delete_request(case_a, &cookie)) .await .unwrap(); - // Batch IDs derive from `now_rfc3339()`, which rounds to whole - // seconds — so the sleep must cross a second boundary for the two - // deletes to land in distinct batches. + // `closed_at` is `now_rfc3339()` with whole-second granularity — the + // sleep must cross a second boundary so the two closes land in + // distinct groups. tokio::time::sleep(Duration::from_millis(1100)).await; let _ = app .clone() @@ -712,16 +712,16 @@ async fn undo_delete_restores_latest_batch_only() { .unwrap(); assert_eq!(undo.status(), StatusCode::SEE_OTHER); - // case_b (latest delete) restored, case_a still deleted. + // case_b (latest close) restored, case_a still closed. assert!( - !dir_b.join(".deleted").exists(), + !dir_b.join(".closed").exists(), "case_b marker should be gone" ); - assert!(dir_a.join(".deleted").exists(), "case_a marker must remain"); + assert!(dir_a.join(".closed").exists(), "case_a marker must remain"); } #[tokio::test] -async fn bulk_delete_shares_one_batch_uuid() { +async fn bulk_delete_shares_one_closed_at_timestamp() { let config = config_with_llm(unique_tmp("bulk-d"), "http://unused".into()); let case_a = "11111111-1111-1111-1111-111111111111"; let case_b = "22222222-2222-2222-2222-222222222222"; @@ -749,12 +749,12 @@ async fn bulk_delete_shares_one_batch_uuid() { assert_eq!(resp.status(), StatusCode::SEE_OTHER); let m_a: Value = - serde_json::from_str(&std::fs::read_to_string(dir_a.join(".deleted")).unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(dir_a.join(".closed")).unwrap()).unwrap(); let m_b: Value = - serde_json::from_str(&std::fs::read_to_string(dir_b.join(".deleted")).unwrap()).unwrap(); + serde_json::from_str(&std::fs::read_to_string(dir_b.join(".closed")).unwrap()).unwrap(); assert_eq!( - m_a["batch"], m_b["batch"], - "bulk-delete must share batch UUID" + m_a["closed_at"], m_b["closed_at"], + "bulk-delete must share one closed_at timestamp so undo can group them" ); } @@ -797,8 +797,8 @@ async fn recovery_skips_deleted_cases() { std::fs::create_dir_all(&case_dir).unwrap(); std::fs::write(case_dir.join("analysis_input.json"), "{}").unwrap(); std::fs::write( - case_dir.join(".deleted"), - r#"{"batch":"00000000-0000-0000-0000-000000000000","deleted_at":"2026-04-15T10:00:00Z"}"#, + case_dir.join(".closed"), + r#"{"closed_at":"2026-04-15T10:00:00Z"}"#, ) .unwrap(); diff --git a/server/tests/oneliners_api_test.rs b/server/tests/oneliners_api_test.rs index 8ed3d51..dc4898a 100644 --- a/server/tests/oneliners_api_test.rs +++ b/server/tests/oneliners_api_test.rs @@ -75,7 +75,7 @@ async fn write_state(case_dir: &Path, state: &OnelinerState) { } async fn mark_deleted(case_dir: &Path) { - tokio::fs::write(case_dir.join(".deleted"), "{}") + tokio::fs::write(case_dir.join(".closed"), "{}") .await .unwrap(); } diff --git a/server/tests/upload_test.rs b/server/tests/upload_test.rs index 6545269..4469487 100644 --- a/server/tests/upload_test.rs +++ b/server/tests/upload_test.rs @@ -6,7 +6,7 @@ use axum::http::{Request, StatusCode}; use tower::util::ServiceExt; use doctate_server::config::{Config, User}; -use doctate_server::paths::{DELETE_MARKER, DeleteMarker, write_delete_marker}; +use doctate_server::paths::{CLOSE_MARKER, CloseMarker, write_close_marker}; fn test_config() -> Arc { let data_path = std::env::temp_dir().join(format!( @@ -247,11 +247,11 @@ async fn upload_second_recording_same_case() { let _ = std::fs::remove_dir_all(&data_path); } -/// Scenario A: a new upload to a *soft-deleted* case must silently -/// remove the `.deleted` marker and store the new audio. Reopens the -/// case so it reappears in /api/oneliners without any manual "undo". +/// Scenario A: a new upload to a *closed* case must silently remove +/// the `.closed` marker and store the new audio. Reopens the case so +/// it reappears in /api/oneliners without any manual "undo". #[tokio::test] -async fn upload_reopens_soft_deleted_case() { +async fn upload_reopens_closed_case() { let config = test_config(); let data_path = config.data_path.clone(); let app = doctate_server::create_router(config); @@ -278,18 +278,17 @@ async fn upload_reopens_soft_deleted_case() { .unwrap(); assert_eq!(response.status(), StatusCode::OK); - // Soft-delete the case by writing the marker directly — simulates - // the user hitting the delete button in the web UI without having - // to drive the /web/cases/{id}/delete handler here. Direct write - // also means the watermark is NOT bumped here; that way the next - // GET /api/oneliners pins down the pre-reopen ETag cleanly. + // Close the case by writing the marker directly — simulates the + // user hitting the close button in the web UI without having to + // drive the /web/cases/{id}/delete handler here. Direct write also + // means the watermark is NOT bumped here; that way the next GET + // /api/oneliners pins down the pre-reopen ETag cleanly. let case_dir = data_path.join("dr_test").join(case_id); - let marker = DeleteMarker { - batch: uuid::Uuid::new_v4(), - deleted_at: "2026-04-13T11:00:00Z".into(), + let marker = CloseMarker { + closed_at: "2026-04-13T11:00:00Z".into(), }; - write_delete_marker(&case_dir, &marker).await.unwrap(); - assert!(case_dir.join(DELETE_MARKER).exists()); + write_close_marker(&case_dir, &marker).await.unwrap(); + assert!(case_dir.join(CLOSE_MARKER).exists()); // Capture the ETag before the reopen. let etag_before = { @@ -335,8 +334,8 @@ async fn upload_reopens_soft_deleted_case() { // Marker is gone → case is visible again. assert!( - !case_dir.join(DELETE_MARKER).exists(), - ".deleted marker must be removed after re-upload" + !case_dir.join(CLOSE_MARKER).exists(), + ".closed marker must be removed after re-upload" ); // Both audios are on disk — the old one was not touched. assert!(case_dir.join("2026-04-13T10-30-00Z.m4a").exists());