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() {
|
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;
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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());
|
||||||
|
|||||||
Reference in New Issue
Block a user