refactor: replace delete/undo flow with close/reopen endpoints
- POST /web/cases/:id/delete renamed to /close; emits CaseClosed. - New POST /web/cases/:id/reopen removes the marker; emits CaseReopened. - Bulk action "delete" renamed to "close"; the shared closed_at timestamp still groups the selection for future UI features. - POST /web/cases/undo-delete removed along with summarize_latest_close_group, latest_close_timestamp and restore_close_group. Per-case reopen makes the group-undo banner obsolete. - CaseEventKind::CaseDeleted -> CaseClosed, CaseRestored -> CaseReopened. - Templates updated to Lucide trash-2 icon, German label "Fall schliessen". - Tests migrated; delete_watermark_test.rs moved to close_watermark_test.rs.
This commit is contained in:
@@ -30,8 +30,8 @@ pub enum CaseEventKind {
|
||||
OnelinerUpdated,
|
||||
AnalysisQueued,
|
||||
DocumentReady,
|
||||
CaseDeleted,
|
||||
CaseRestored,
|
||||
CaseClosed,
|
||||
CaseReopened,
|
||||
CaseReset,
|
||||
}
|
||||
|
||||
|
||||
+10
-12
@@ -27,9 +27,9 @@ pub struct BulkForm {
|
||||
case_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// POST /web/cases/bulk — apply `action` (analyze | delete) to every
|
||||
/// selected case. Errors per case are logged but do not abort the batch:
|
||||
/// a single bad/missing case must not block the rest.
|
||||
/// POST /web/cases/bulk — apply `action` (analyze | close | reset) to
|
||||
/// every selected case. Errors per case are logged but do not abort the
|
||||
/// batch: a single bad/missing case must not block the rest.
|
||||
pub async fn handle_bulk_action(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
@@ -51,7 +51,7 @@ pub async fn handle_bulk_action(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"delete" => bulk_delete(&events_tx, &user.slug, &user_root, &form.case_ids).await,
|
||||
"close" => bulk_close(&events_tx, &user.slug, &user_root, &form.case_ids).await,
|
||||
"reset" => {
|
||||
if !user.is_admin() {
|
||||
warn!(slug = %user.slug, "bulk-reset: not admin");
|
||||
@@ -133,25 +133,23 @@ async fn bulk_analyze(
|
||||
info!(slug = %slug, ok, skipped, "bulk-analyze completed");
|
||||
}
|
||||
|
||||
async fn bulk_delete(
|
||||
async fn bulk_close(
|
||||
events_tx: &EventSender,
|
||||
slug: &str,
|
||||
user_root: &std::path::Path,
|
||||
case_ids: &[String],
|
||||
) {
|
||||
// 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 {
|
||||
if uuid::Uuid::parse_str(case_id).is_err() {
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-delete: invalid case_id");
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-close: invalid case_id");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(case_dir) = locate_case(user_root, case_id).await else {
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-delete: case not found");
|
||||
warn!(slug = %slug, case_id = %case_id, "bulk-close: case not found");
|
||||
skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -159,14 +157,14 @@ async fn bulk_delete(
|
||||
closed_at: closed_at.clone(),
|
||||
};
|
||||
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-close: write marker failed");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
events::emit(events_tx, slug, case_id, CaseEventKind::CaseDeleted);
|
||||
events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed);
|
||||
ok += 1;
|
||||
}
|
||||
info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-delete completed");
|
||||
info!(slug = %slug, closed_at = %closed_at, ok, skipped, "bulk-close 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::{CLOSE_MARKER, CloseMarker, read_close_marker, write_close_marker};
|
||||
use crate::paths::{CLOSE_MARKER, CloseMarker, write_close_marker};
|
||||
use crate::routes::user_web::locate_case_or_404;
|
||||
use crate::routes::web::validate_filename;
|
||||
|
||||
@@ -276,20 +276,20 @@ pub(crate) async fn reset_case_artefacts(case_dir: &Path) -> std::io::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/delete
|
||||
/// POST /web/cases/{case_id}/close
|
||||
///
|
||||
/// 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 `closed_at` timestamp among the user's closed cases.
|
||||
pub async fn handle_delete_case(
|
||||
/// Reversible via `handle_reopen_case` (explicit button) or by uploading
|
||||
/// a new recording for the same case (automatic via `handle_upload`).
|
||||
pub async fn handle_close_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
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, "close").await?;
|
||||
|
||||
let marker = CloseMarker {
|
||||
closed_at: now_rfc3339(),
|
||||
@@ -300,14 +300,60 @@ pub async fn handle_delete_case(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
closed_at = %marker.closed_at,
|
||||
"case closed"
|
||||
"case closed (manual)"
|
||||
);
|
||||
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
case_id.to_string(),
|
||||
CaseEventKind::CaseDeleted,
|
||||
CaseEventKind::CaseClosed,
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
}
|
||||
|
||||
/// POST /web/cases/{case_id}/reopen
|
||||
///
|
||||
/// Reopen a closed case: removes the `.closed` marker. The case is
|
||||
/// immediately back in the default listing. A reopened case is
|
||||
/// indistinguishable from one that was never closed — no status
|
||||
/// remainders on disk. Idempotent: an already-open case is a no-op.
|
||||
pub async fn handle_reopen_case(
|
||||
user: AuthenticatedWebUser,
|
||||
State(config): State<Arc<Config>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
CaseIdPath(case_id): CaseIdPath,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
let case_dir = user_root.join(case_id.to_string());
|
||||
|
||||
if !crate::paths::is_closed(&case_dir).await {
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
"reopen requested but case is not closed"
|
||||
);
|
||||
return Ok(Redirect::to("/web/cases"));
|
||||
}
|
||||
|
||||
match tokio::fs::remove_file(case_dir.join(CLOSE_MARKER)).await {
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(AppError::Internal(format!("remove close marker: {e}"))),
|
||||
}
|
||||
|
||||
info!(
|
||||
slug = %user.slug,
|
||||
case_id = %case_id,
|
||||
"case reopened (manual)"
|
||||
);
|
||||
|
||||
events::emit(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
case_id.to_string(),
|
||||
CaseEventKind::CaseReopened,
|
||||
);
|
||||
|
||||
Ok(Redirect::to("/web/cases"))
|
||||
@@ -348,115 +394,6 @@ pub async fn handle_reset_case(
|
||||
Ok(Redirect::to(&resolve_return_path(&headers)))
|
||||
}
|
||||
|
||||
/// POST /web/cases/undo-delete
|
||||
///
|
||||
/// 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>>,
|
||||
State(events_tx): State<EventSender>,
|
||||
) -> Result<Redirect, AppError> {
|
||||
let user_root = config.data_path.join(&user.slug);
|
||||
|
||||
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_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 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 {
|
||||
let case_path = entry.path();
|
||||
if !case_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Some(marker) = read_close_marker(&case_path).await
|
||||
&& marker.closed_at == group_ts
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Some((group_ts, count))
|
||||
}
|
||||
|
||||
/// 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<String> = None;
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
if !entry.path().is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(marker) = read_close_marker(&entry.path()).await else {
|
||||
continue;
|
||||
};
|
||||
best = match best {
|
||||
None => Some(marker.closed_at),
|
||||
Some(cur) if marker.closed_at > cur => Some(marker.closed_at),
|
||||
other => other,
|
||||
};
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// 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,
|
||||
group_ts: &str,
|
||||
events_tx: &EventSender,
|
||||
user_slug: &str,
|
||||
) -> usize {
|
||||
let mut count = 0;
|
||||
let mut entries = match tokio::fs::read_dir(user_root).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||
let case_path = entry.path();
|
||||
if !case_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(marker) = read_close_marker(&case_path).await else {
|
||||
continue;
|
||||
};
|
||||
if marker.closed_at != group_ts {
|
||||
continue;
|
||||
}
|
||||
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(
|
||||
events_tx,
|
||||
user_slug,
|
||||
events::case_id_of(&case_path),
|
||||
CaseEventKind::CaseRestored,
|
||||
);
|
||||
count += 1;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRecordingForm {
|
||||
pub filename: String,
|
||||
|
||||
@@ -43,17 +43,17 @@ pub fn api_router() -> Router<AppState> {
|
||||
post(case_actions::handle_analyze_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/delete",
|
||||
post(case_actions::handle_delete_case),
|
||||
"/web/cases/{case_id}/close",
|
||||
post(case_actions::handle_close_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/reopen",
|
||||
post(case_actions::handle_reopen_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/{case_id}/reset",
|
||||
post(case_actions::handle_reset_case),
|
||||
)
|
||||
.route(
|
||||
"/web/cases/undo-delete",
|
||||
post(case_actions::handle_undo_delete),
|
||||
)
|
||||
.route("/web/cases/bulk", post(bulk::handle_bulk_action))
|
||||
.route(
|
||||
"/web/audio/{user}/{case_id}/{filename}",
|
||||
|
||||
@@ -91,7 +91,7 @@ pub async fn handle_upload(
|
||||
&events_tx,
|
||||
&user.slug,
|
||||
&case_id,
|
||||
CaseEventKind::CaseRestored,
|
||||
CaseEventKind::CaseReopened,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -152,9 +152,6 @@ struct MyCasesTemplate {
|
||||
slug: String,
|
||||
groups: Vec<DateGroup>,
|
||||
total: usize,
|
||||
/// Number of cases that "Undo last delete" would restore. 0 hides the
|
||||
/// undo button entirely.
|
||||
undo_count: usize,
|
||||
/// True iff the session user is an admin — toggles full case_id display.
|
||||
is_admin: bool,
|
||||
}
|
||||
@@ -319,16 +316,11 @@ 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_close_group(&user_root)
|
||||
.await
|
||||
.map(|(_, n)| n)
|
||||
.unwrap_or(0);
|
||||
let is_admin = user.is_admin();
|
||||
MyCasesTemplate {
|
||||
slug: user.slug,
|
||||
groups,
|
||||
total,
|
||||
undo_count,
|
||||
is_admin,
|
||||
}
|
||||
.render()
|
||||
|
||||
Reference in New Issue
Block a user