//! 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; /// 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, case_id: impl Into, kind: CaseEventKind, ) { let _ = tx.send(CaseEvent { user_slug: user_slug.into(), case_id: case_id.into(), kind, ts: doctate_common::now_rfc3339(), }); } /// Extract `` from a `///` 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 `` from a `///` 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); } }