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
+42 -47
View File
@@ -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(