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