Files
doctate/server/src/events.rs
T
Brummel 8173ff2f26 feat: add POST /web/cases/purge-closed for bulk hard-delete
Iterates the user's data dir and removes every case directory that
carries a .closed marker. Requires form field confirm=yes as a server-
side guard against accidental browser-history re-POSTs. Emits CasePurged
per successfully removed case; per-case failures are logged but do not
abort the sweep.

UI button is deferred to the show-closed step so the endpoint is only
reachable after an explicit navigation. Three integration tests cover
the happy path, missing-confirm, and coexistence with open cases.
2026-04-21 10:46:07 +02:00

146 lines
4.6 KiB
Rust

//! Broadcast bus for UI live-update events.
//!
//! The web UI subscribes via the `/web/events` SSE route (see
//! `routes::events`). Background workers and request handlers call
//! [`emit`] whenever they mutate case data on disk; subscribed browsers
//! then schedule a debounced reload. The filesystem remains the source
//! of truth — these events are pure triggers, never state.
use std::path::Path;
use serde::Serialize;
use tokio::sync::broadcast;
/// Broadcast capacity. Oversized on purpose: even a "bulk analyze 50 cases"
/// operation emits at most ~5 events per case (Queued/Transcript/Oneliner/
/// Document/Done), well under 256. Subscribers more than 256 events behind
/// receive a `Lagged(n)` on their next recv; they are re-synced by their
/// next page reload anyway, so skipping is safe.
pub const EVENT_CHANNEL_CAPACITY: usize = 256;
/// All UI-observable mutations on a case. Serialized as a `kind` field
/// in snake_case so the client can filter with e.g. `evt.kind === "transcript_ready"`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CaseEventKind {
RecordingUploaded,
RecordingDeleted,
TranscriptReady,
TranscriptFailed,
OnelinerUpdated,
AnalysisQueued,
DocumentReady,
CaseClosed,
CaseReopened,
CasePurged,
CaseReset,
}
/// A single state-change notification. Clone is required because
/// `tokio::sync::broadcast` hands every subscriber its own copy.
#[derive(Debug, Clone, Serialize)]
pub struct CaseEvent {
pub user_slug: String,
pub case_id: String,
pub kind: CaseEventKind,
pub ts: String,
}
/// Multi-producer, multi-consumer sender shared via `AppState`.
pub type EventSender = broadcast::Sender<CaseEvent>;
/// Construct a fresh broadcast channel sized with [`EVENT_CHANNEL_CAPACITY`].
/// The unused receiver is dropped — subscribers call
/// [`EventSender::subscribe`] on the returned sender.
pub fn channel() -> EventSender {
let (tx, _rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
tx
}
/// Best-effort publish. `broadcast::Sender::send` returns `Err` only when
/// there are zero subscribers — which is the normal state before any
/// browser connects — so we swallow the error silently. Never blocks.
pub fn emit(
tx: &EventSender,
user_slug: impl Into<String>,
case_id: impl Into<String>,
kind: CaseEventKind,
) {
let _ = tx.send(CaseEvent {
user_slug: user_slug.into(),
case_id: case_id.into(),
kind,
ts: doctate_common::now_rfc3339(),
});
}
/// Extract `<user_slug>` from a `<data_path>/<user_slug>/<case_id>/` path.
/// Returns the empty string on malformed input. Empty slugs still flow
/// through the admin filter (admins see them) but are filtered out for
/// non-admins, which is the safe default.
pub fn user_slug_of(case_dir: &Path) -> String {
case_dir
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.map(str::to_owned)
.unwrap_or_default()
}
/// Extract `<case_id>` from a `<data_path>/<user_slug>/<case_id>/` path.
/// Returns the empty string on malformed input.
pub fn case_id_of(case_dir: &Path) -> String {
case_dir
.file_name()
.and_then(|s| s.to_str())
.map(str::to_owned)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn two_subscribers_both_receive() {
let tx = channel();
let mut a = tx.subscribe();
let mut b = tx.subscribe();
emit(&tx, "alice", "c1", CaseEventKind::RecordingUploaded);
let ea = a.recv().await.unwrap();
let eb = b.recv().await.unwrap();
assert_eq!(ea.user_slug, "alice");
assert_eq!(eb.case_id, "c1");
assert!(matches!(ea.kind, CaseEventKind::RecordingUploaded));
}
#[tokio::test]
async fn slow_subscriber_sees_lagged() {
let tx = channel();
let mut rx = tx.subscribe();
// Fill the channel past capacity without recv'ing.
for _ in 0..(EVENT_CHANNEL_CAPACITY + 5) {
emit(&tx, "u", "c", CaseEventKind::TranscriptReady);
}
let err = rx.recv().await.unwrap_err();
assert!(matches!(err, broadcast::error::RecvError::Lagged(_)));
}
#[test]
fn user_slug_extracted_from_case_dir() {
let p = Path::new("/data/alice/550e8400-e29b-41d4-a716-446655440000");
assert_eq!(user_slug_of(p), "alice");
}
#[test]
fn user_slug_empty_on_root_path() {
assert_eq!(user_slug_of(Path::new("/")), "");
}
#[test]
fn emit_with_no_subscribers_does_not_panic() {
let tx = channel();
emit(&tx, "u", "c", CaseEventKind::DocumentReady);
}
}