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:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+22
-22
@@ -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<DeleteMarker> {
|
||||
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<CloseMarker> {
|
||||
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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Arc<Config>>,
|
||||
@@ -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<Arc<Config>>,
|
||||
@@ -362,23 +362,22 @@ pub async fn handle_undo_delete(
|
||||
) -> Result<Redirect, AppError> {
|
||||
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<String> {
|
||||
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 {
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 `<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.
|
||||
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) {
|
||||
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<UserCaseView> {
|
||||
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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,7 +46,7 @@ pub async fn enqueue_pending_for_user(
|
||||
let mut pending: Vec<std::path::PathBuf> = 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<PathBuf>
|
||||
};
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+16
-17
@@ -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<Config> {
|
||||
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());
|
||||
|
||||
Reference in New Issue
Block a user