//! Lazy retention sweep: runs at the top of `scan_user_cases` for the //! signed-in user. Auto-closes stale cases and auto-purges old closed //! cases — no background scheduler, no cron. The natural "someone //! opened the case list" trigger is sufficient for doctate's single- //! digit-user scale. //! //! Logging is deliberate per `feedback_log_autonomous_server_actions`: //! every automatic action — and every race-guarded skip — emits an //! `info!` line with the reason and the applied thresholds. //! //! Sweep order per case: auto-close first, auto-purge second. A case //! that was just auto-closed has `closed_at ≈ now` and naturally //! survives the purge check — giving the user the full `auto_delete_days` //! grace period even after a long absence. use std::path::Path; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use tokio::fs; use tracing::{info, warn}; use doctate_common::timestamp::now_rfc3339; use crate::config::RetentionUserSettings; use crate::events::{self, CaseEventKind, EventSender}; use crate::paths::{CloseMarker, is_closed, read_close_marker, write_close_marker}; /// Scan `user_root` once, applying auto-close and auto-purge under the /// user's retention policy. Idempotent and best-effort: every I/O /// failure is logged but never blocks the remaining cases or the /// enclosing list view. pub async fn sweep_user_retention( user_root: &Path, slug: &str, settings: &RetentionUserSettings, events_tx: &EventSender, ) { if settings.auto_close_days == 0 && settings.auto_delete_days == 0 { return; } let now = OffsetDateTime::now_utc(); let mut entries = match fs::read_dir(user_root).await { Ok(r) => r, // User dir may not exist yet (new user, no uploads). Nothing to sweep. Err(_) => return, }; while let Ok(Some(entry)) = entries.next_entry().await { let case_path = entry.path(); if !case_path.is_dir() { continue; } if settings.auto_close_days > 0 && !is_closed(&case_path).await { try_auto_close(&case_path, slug, settings.auto_close_days, now, events_tx).await; } if settings.auto_delete_days > 0 && is_closed(&case_path).await { try_auto_purge(&case_path, slug, settings.auto_delete_days, now, events_tx).await; } } } async fn try_auto_close( case_path: &Path, slug: &str, threshold_days: u32, now: OffsetDateTime, events_tx: &EventSender, ) { let Some(latest_m4a_mtime) = newest_m4a_mtime(case_path).await else { // No .m4a yet → not a candidate. return; }; let age_days = (now - latest_m4a_mtime).whole_days(); if age_days <= threshold_days as i64 { return; } let marker = CloseMarker { closed_at: now_rfc3339(), }; let case_id = events::case_id_of(case_path); match write_close_marker(case_path, &marker).await { Ok(_) => { info!( slug = %slug, case_id = %case_id, last_recording_age_days = age_days, threshold_days = threshold_days, "auto-closed case: last recording older than threshold" ); events::emit(events_tx, slug, case_id, CaseEventKind::CaseClosed); } Err(e) => { warn!( slug = %slug, case_id = %case_id, error = %e, "auto-close failed to write marker" ); } } } async fn try_auto_purge( case_path: &Path, slug: &str, threshold_days: u32, now: OffsetDateTime, events_tx: &EventSender, ) { let Some(marker) = read_close_marker(case_path).await else { // Malformed marker → do not purge. Admin can manually fix. return; }; let Ok(closed_at) = OffsetDateTime::parse(&marker.closed_at, &Rfc3339) else { warn!( slug = %slug, case_dir = ?case_path, closed_at = %marker.closed_at, "auto-purge: cannot parse closed_at; skipping" ); return; }; let age_days = (now - closed_at).whole_days(); if age_days <= threshold_days as i64 { return; } // Race-guard: re-read the marker immediately before `remove_dir_all`. // If a concurrent upload has cleared the marker, the case has been // reopened and must not be purged. if read_close_marker(case_path).await.is_none() { info!( slug = %slug, case_dir = ?case_path, reason = "reopened during purge", "auto-purge skipped" ); return; } let case_id = events::case_id_of(case_path); match fs::remove_dir_all(case_path).await { Ok(_) => { info!( slug = %slug, case_id = %case_id, closed_days_ago = age_days, threshold_days = threshold_days, "auto-purged case: closed marker older than threshold" ); events::emit(events_tx, slug, case_id, CaseEventKind::CasePurged); } Err(e) => { warn!( slug = %slug, case_id = %case_id, error = %e, "auto-purge failed to remove dir" ); } } } /// Newest `.m4a` mtime in `case_dir`, as a UTC `OffsetDateTime`. /// `None` when no `.m4a` is present. `.m4a.failed` counts too — a /// recording that failed ffmpeg is still user activity. async fn newest_m4a_mtime(case_dir: &Path) -> Option { let mut entries = fs::read_dir(case_dir).await.ok()?; let mut newest: Option = None; while let Ok(Some(entry)) = entries.next_entry().await { let name = entry.file_name(); let Some(s) = name.to_str() else { continue }; if !s.ends_with(".m4a") && !s.ends_with(".m4a.failed") { continue; } let Ok(meta) = entry.metadata().await else { continue; }; let Ok(mtime) = meta.modified() else { continue; }; let ts = OffsetDateTime::from(mtime); newest = Some(match newest { Some(cur) if cur > ts => cur, _ => ts, }); } newest }