backbone_pos/application/service/pos_session_alert.rs
1//! Old-session alert (hand-authored, user-owned): the handler behind the module's ONE scheduled job.
2//!
3//! An `impl PosWriteService` chunk over the vocabulary in [`super::pos_write_service`]. Open cashier
4//! sessions that outlive the threshold (7 days by default — a session that old is a forgotten
5//! drawer, not a long shift) get claimed, latched, and announced.
6//!
7//! Shape decisions (the scheduler declaration in `schema/hooks/index.hook.yaml` records the same):
8//!
9//! - **Per-unit handler (ADR-0029).** The host enumerates its org units and calls this once per
10//! unit with that unit's scope bound, so the module itself never does a cross-tenant read — the
11//! fence stays meaningful even inside a job.
12//! - **Once-only latch.** The claim stamps `stale_session_alerted_at` into the session's `metadata`
13//! in the SAME transaction that claims it (`commit_policy: single_transaction`), so a daily
14//! scheduler never re-nags the same session; events are emitted only after that commit.
15//! - **Pickup lock.** The claim reads `FOR UPDATE SKIP LOCKED` (ADR-0020 §4), so two concurrent
16//! hosts draining the same schedule each take disjoint sessions instead of colliding.
17//! - **Mail is a logical link, not a Cargo edge.** The emitted [`PosStaleSessionAlerted`] carries the
18//! cashier as a party ref; the COMPOSING SERVICE subscribes and schedules the "session open too
19//! long" activity on that cashier. This module depends on no mail crate.
20//!
21//! Per the module's 4-layer rule this file holds no SQL — the claim/stamp live on
22//! `PosOpeningEntryRepository`.
23
24use uuid::Uuid;
25
26use crate::infrastructure::persistence::StaleSessionRow;
27
28use super::pos_events::{PosEvent, PosStaleSessionAlerted};
29use super::pos_write_service::{legacy_company_echo, relay_ambient_scope, PosError, PosWriteService};
30
31/// A session older than this is stale. 7 days is the default; the host may pass its own threshold.
32pub const DEFAULT_STALE_SESSION_AGE_DAYS: i64 = 7;
33
34/// Upper bound on sessions alerted in one run — the job stays bounded on a unit that somehow
35/// accumulated years of open drawers. The next run picks up what this one skipped (the latch is only
36/// stamped on alerted sessions, never on skipped ones).
37pub const STALE_SESSION_BATCH_LIMIT: i64 = 500;
38
39impl PosWriteService {
40 /// Alert the caller's scope's stale open sessions. Returns the alerts that fired (empty =
41 /// nothing stale or everything already latched). Idempotent per session: a rerun re-alerts
42 /// nothing.
43 pub async fn alert_old_sessions(
44 &self,
45 older_than: chrono::Duration,
46 ) -> Result<Vec<PosStaleSessionAlerted>, PosError> {
47 let cutoff = chrono::Utc::now().naive_utc() - older_than;
48 // Tenancy (ADR-0029): the whole claim + latch runs inside the caller's ambient org scope, on
49 // one connection, as one transaction — exactly the pattern the write verbs follow. Under a
50 // non-bypassing role the re-bound fence is what makes the claim read anything at all.
51 let mut tx = self.rpool().begin().await?;
52 relay_ambient_scope(&mut tx).await?;
53 let claimed = self
54 .openings
55 .claim_stale_sessions(&mut tx, cutoff, STALE_SESSION_BATCH_LIMIT)
56 .await?;
57 if claimed.is_empty() {
58 tx.rollback().await?;
59 return Ok(Vec::new());
60 }
61 let at = chrono::Utc::now();
62 let ids: Vec<Uuid> = claimed.iter().map(|s| s.opening_entry_id).collect();
63 self.openings.mark_stale_alerted(&mut tx, &ids, at).await?;
64 tx.commit().await?;
65 let claimed: Vec<StaleSessionRow> = claimed;
66
67 // Emit only after the latch committed — a crash between commit and emit loses one alert run
68 // for that session (acceptable: the latch is the durable record; the host can re-derive), but
69 // the reverse order would re-alert forever. The `company_id` field is the documented legacy
70 // twin (ADR-0029) — the acting unit id from the ambient scope's echo.
71 let alerts = claimed
72 .into_iter()
73 .map(|s| PosStaleSessionAlerted {
74 opening_entry_id: s.opening_entry_id,
75 pos_profile_id: s.pos_profile_id,
76 company_id: legacy_company_echo(),
77 cashier_party_id: s.cashier_party_id,
78 opened_at: s.opened_at,
79 })
80 .collect::<Vec<_>>();
81 for a in &alerts {
82 self.sink.publish(PosEvent::PosStaleSessionAlerted(a.clone()));
83 }
84 Ok(alerts)
85 }
86}