Skip to main content

backbone_support/application/service/
support_write_service.rs

1//! The hand-authored support write path (user-owned; survives regen).
2//!
3//! An SLA-clock ticketing engine + warranty claims. Posts NO GL and owns no money. The load-bearing
4//! logic is the SLA clock: an Issue binds an SLA at raise time, snapshots concrete `response_by` /
5//! `resolution_by` deadlines from the matching priority, can be PAUSED (the paused span is added back to
6//! the deadlines on resume), and on resolve is judged `fulfilled` iff it beat the (pause-adjusted)
7//! resolution deadline. The one outbound seam — escalate a ticket into a real backbone-project delivery
8//! Project — runs through `ProjectPort` (zero normal Cargo edge), idempotent per issue.
9//!
10//! Tenancy: none, by design (ADR-0029). The module is tenant-agnostic — no tenant key on any
11//! write, no scope binding of its own. The COMPOSING service owns the posture: when it
12//! mounts these routes under an auth middleware that binds a row scope (e.g.
13//! `with_org_request_scope`), the database fence owns tenant isolation; a deployment that mounts
14//! them unfenced gets an unfenced module. Multi-statement transactions here relay an ambient
15//! scope onto the transaction when one is bound, and stay plain otherwise. The ONE exception is
16//! the escalation seam: the sibling port's delivery-Project row still carries the sibling's
17//! legacy owner-company key, sourced fail-closed from the ambient org scope.
18//!
19//! Clock verbs take an explicit `now: DateTime<Utc>` so the deadline math is deterministic under test.
20
21use backbone_orm::org_scope;
22use chrono::{DateTime, Duration, Utc};
23use sqlx::PgPool;
24use uuid::Uuid;
25
26use crate::infrastructure::persistence::{
27    IssueRepository, NewIssueRow, NewSlaPriorityRow, NewSlaRow, NewWarrantyClaimRow,
28    ServiceLevelAgreementRepository, ServiceLevelPriorityRepository, WarrantyClaimRepository,
29};
30
31use super::support_events::*;
32use super::support_ports::*;
33
34#[derive(Debug, thiserror::Error)]
35pub enum SupportError {
36    #[error("db: {0}")]
37    Db(#[from] sqlx::Error),
38    #[error("not found: {0}")]
39    NotFound(&'static str),
40    #[error("invalid state: {0}")]
41    InvalidState(&'static str),
42    #[error("invalid input: {0}")]
43    Invalid(String),
44    #[error("project rejected: {0}")]
45    ProjectRejected(String),
46    /// The escalation seam needs the sibling's legacy owner-company key (backbone-project is
47    /// not tenant-agnostic) and the outbox mirror is company-keyed, but no org scope carrying
48    /// a company node was bound on this request. Fails closed rather than guessing a key.
49    #[error("no company in the ambient org scope — escalation requires one (composition-installed tenancy, ADR-0029)")]
50    NoCompanyScope,
51}
52
53pub struct NewSlaPriority {
54    pub priority: String, // issue_priority variant
55    pub response_time_mins: i32,
56    pub resolution_time_mins: i32,
57}
58pub struct NewSla {
59    pub name: String,
60    pub is_default: bool,
61    pub priorities: Vec<NewSlaPriority>,
62}
63
64pub struct NewIssue {
65    pub customer_id: Option<Uuid>,
66    pub subject: String,
67    pub description: Option<String>,
68    pub priority: String, // issue_priority variant
69    pub sla_id: Option<Uuid>,
70}
71
72pub struct NewWarrantyClaim {
73    pub customer_id: Option<Uuid>,
74    pub item_id: Uuid,
75    pub serial_no: Option<String>,
76    pub warranty_expiry: Option<DateTime<Utc>>,
77    pub issue_id: Option<Uuid>,
78    pub description: Option<String>,
79}
80
81pub struct SupportWriteService {
82    pool: PgPool,
83    slas: ServiceLevelAgreementRepository,
84    sla_priorities: ServiceLevelPriorityRepository,
85    issues: IssueRepository,
86    warranty_claims: WarrantyClaimRepository,
87}
88
89impl SupportWriteService {
90    pub fn new(pool: PgPool) -> Self {
91        let slas = ServiceLevelAgreementRepository::new(pool.clone());
92        let sla_priorities = ServiceLevelPriorityRepository::new(pool.clone());
93        let issues = IssueRepository::new(pool.clone());
94        let warranty_claims = WarrantyClaimRepository::new(pool.clone());
95        Self {
96            pool,
97            slas,
98            sla_priorities,
99            issues,
100            warranty_claims,
101        }
102    }
103
104    /// Define an SLA with its per-priority first-response + resolution targets. Each target's resolution
105    /// time must be >= its response time; at least one priority row is required.
106    pub async fn create_sla(&self, s: NewSla) -> Result<Uuid, SupportError> {
107        if s.name.trim().is_empty() {
108            return Err(SupportError::Invalid("SLA needs a name".into()));
109        }
110        if s.priorities.is_empty() {
111            return Err(SupportError::Invalid(
112                "an SLA needs at least one priority target".into(),
113            ));
114        }
115        for p in &s.priorities {
116            if p.response_time_mins < 0 || p.resolution_time_mins < 0 {
117                return Err(SupportError::Invalid(
118                    "SLA times must be non-negative".into(),
119                ));
120            }
121            if p.resolution_time_mins < p.response_time_mins {
122                return Err(SupportError::Invalid(
123                    "resolution target must be >= response target".into(),
124                ));
125            }
126        }
127        let id = Uuid::new_v4();
128        let mut tx = self.pool.begin().await?;
129        // Propagate the ambient request scope, when one is bound, onto this transaction: the
130        // repositories' scoped helpers ride the request-dedicated connection, but this pool
131        // transaction does not, and rows a deployment's fence decorates are invisible to an
132        // unscoped connection. Binding the ambient scope relay-only keeps the module
133        // posture-agnostic — unfenced deployments have no ambient scope and skip this entirely.
134        if let Some(scope) = org_scope::current_org_scope() {
135            org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
136        }
137        self.slas
138            .insert_sla(
139                &mut tx,
140                &NewSlaRow {
141                    id,
142                    name: &s.name,
143                    is_default: s.is_default,
144                },
145            )
146            .await?;
147        for p in &s.priorities {
148            self.sla_priorities
149                .insert_priority(
150                    &mut tx,
151                    &NewSlaPriorityRow {
152                        id: Uuid::new_v4(),
153                        sla_id: id,
154                        priority: &p.priority,
155                        response_time_mins: p.response_time_mins,
156                        resolution_time_mins: p.resolution_time_mins,
157                    },
158                )
159                .await?;
160        }
161        tx.commit().await?;
162        Ok(id)
163    }
164
165    /// Raise a ticket. It binds an SLA (the given one, else the default) and snapshots concrete
166    /// `response_by` / `resolution_by` deadlines from the matching priority target. With no SLA the clock
167    /// is untracked (deadlines NULL).
168    pub async fn raise_issue(&self, i: NewIssue, now: DateTime<Utc>) -> Result<Uuid, SupportError> {
169        if i.subject.trim().is_empty() {
170            return Err(SupportError::Invalid("issue needs a subject".into()));
171        }
172        // Resolve the SLA: explicit, else the default. Under a composing service's row fence
173        // (RLS), the scope bound on the request connection limits what the default lookup sees;
174        // with no fence mounted, this is a plain lookup.
175        let sla_id: Option<Uuid> = match i.sla_id {
176            Some(id) => Some(id),
177            None => self.slas.find_default_id(&self.pool).await?,
178        };
179        // Snapshot deadlines from the matching priority target (if an SLA is bound).
180        let (mut response_by, mut resolution_by) = (None, None);
181        if let Some(sid) = sla_id {
182            let target = self
183                .sla_priorities
184                .find_target(&self.pool, sid, &i.priority)
185                .await?
186                .ok_or(SupportError::Invalid(
187                    "SLA has no target for this priority".into(),
188                ))?;
189            response_by = Some(now + Duration::minutes(target.response_time_mins as i64));
190            resolution_by = Some(now + Duration::minutes(target.resolution_time_mins as i64));
191        }
192        let id = Uuid::new_v4();
193        self.issues
194            .insert_issue(
195                &self.pool,
196                &NewIssueRow {
197                    id,
198                    customer_id: i.customer_id,
199                    subject: &i.subject,
200                    description: i.description.as_deref(),
201                    priority: &i.priority,
202                    sla_id,
203                    opened_at: now,
204                    response_by,
205                    resolution_by,
206                },
207            )
208            .await?;
209        Ok(id)
210    }
211
212    /// Record the first response — moves the clock from first-response to resolution tracking, and
213    /// **judges the response leg**: a response after `response_by` breaches the SLA. The breach is
214    /// persisted (`response_breached`) and immediately flips agreement_status to `failed`, because a
215    /// missed first response fails the SLA even if the resolution later lands on time (a met resolution
216    /// no longer masks a blown response — completeness council 2026-07-07). Judged inside the gated
217    /// UPDATE against the row's live `response_by` (pause-adjusted), never a stale read.
218    pub async fn record_first_response(
219        &self,
220        issue_id: Uuid,
221        now: DateTime<Utc>,
222    ) -> Result<(), SupportError> {
223        let moved = self
224            .issues
225            .record_first_response(&self.pool, issue_id, now)
226            .await?;
227        if moved != 1 {
228            return Err(SupportError::InvalidState(
229                "issue is not awaiting a first response",
230            ));
231        }
232        Ok(())
233    }
234
235    /// Pause the SLA clock (ticket on hold — waiting on customer / third party).
236    pub async fn pause_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
237        let moved = self.issues.pause(&self.pool, issue_id, now).await?;
238        if moved != 1 {
239            return Err(SupportError::InvalidState(
240                "only an open/replied issue can be paused",
241            ));
242        }
243        Ok(())
244    }
245
246    /// Resume the SLA clock — add the paused span back to the outstanding deadlines so a hold never
247    /// counts against the SLA. Restores the running status (open if not yet responded, else replied).
248    pub async fn resume_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
249        let mut tx = self.pool.begin().await?;
250        // The locking read + the deadline extension are one transaction on the pool, not a
251        // request-connection statement — relay the ambient request scope onto it, when one is
252        // bound, so a decorated deployment's fence sees the row.
253        if let Some(scope) = org_scope::current_org_scope() {
254            org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
255        }
256        let row = self.issues.lock_on_hold(&mut tx, issue_id).await?;
257        let row = match row {
258            Some(r) => r,
259            None => {
260                tx.rollback().await?;
261                return Err(SupportError::InvalidState("issue is not on hold"));
262            }
263        };
264        let paused_mins = (now - row.paused_at).num_minutes().max(0);
265        let running = if row.first_responded_at.is_some() {
266            "replied"
267        } else {
268            "open"
269        };
270        // Extend the resolution deadline by the paused span; extend the response deadline too while the
271        // first response is still outstanding.
272        self.issues
273            .apply_resume(&mut tx, issue_id, paused_mins as i32, running)
274            .await?;
275        tx.commit().await?;
276        Ok(())
277    }
278
279    /// Resolve a ticket. Judged `fulfilled` iff resolved at/before the (pause-adjusted) resolution
280    /// deadline — a ticket with no SLA is always fulfilled (no promise to breach). Emits `IssueResolved`.
281    pub async fn resolve_issue(
282        &self,
283        issue_id: Uuid,
284        now: DateTime<Utc>,
285        sink: &dyn SupportEventSink,
286    ) -> Result<bool, SupportError> {
287        let issue = self
288            .issues
289            .find_state(&self.pool, issue_id)
290            .await?
291            .ok_or(SupportError::NotFound("issue"))?;
292        if issue.status == "on_hold" {
293            return Err(SupportError::InvalidState(
294                "resume the issue before resolving",
295            ));
296        }
297        if issue.status != "open" && issue.status != "replied" {
298            return Err(SupportError::InvalidState(
299                "only an open/replied issue can be resolved",
300            ));
301        }
302        // Compute the verdict INSIDE the gated UPDATE from the row's LIVE resolution_by — not from the
303        // snapshot read above (which is only for the friendly on_hold/not-found errors). A concurrent
304        // pause+resume that commits between that read and here extends resolution_by and restores an
305        // open/replied status, so a read-then-write split would stamp a stale verdict (a met SLA marked
306        // `failed`). Judging in the same statement as the transition closes that race (maturity council
307        // 2026-07-07). A ticket with no SLA (NULL resolution_by) is always fulfilled.
308        // fulfilled requires BOTH legs met: the resolution is within resolution_by, AND the first
309        // response leg is not breached — either an already-recorded on-time response, or (no response
310        // recorded yet) resolving within response_by counts as responding in time. A ticket that blew
311        // its response deadline — recorded late, or still unanswered past response_by — fails even if
312        // the resolution lands on time (completeness council 2026-07-07).
313        let fulfilled = self
314            .issues
315            .resolve(&self.pool, issue_id, now)
316            .await?
317            .ok_or(SupportError::InvalidState("issue is no longer resolvable"))?;
318        sink.publish(&SupportEvent::IssueResolved(IssueResolved {
319            issue_id,
320            fulfilled,
321        }));
322        Ok(fulfilled)
323    }
324
325    /// Close a resolved ticket (terminal).
326    pub async fn close_issue(&self, issue_id: Uuid) -> Result<(), SupportError> {
327        let moved = self.issues.close(&self.pool, issue_id).await?;
328        if moved != 1 {
329            return Err(SupportError::InvalidState(
330                "only a resolved issue can be closed",
331            ));
332        }
333        Ok(())
334    }
335
336    /// Escalate a ticket into a real backbone-project delivery Project (drives `ProjectPort`, idempotent
337    /// per issue). Transition-gates on `escalated_project_id IS NULL` so a ticket escalates **once**.
338    /// Emits `IssueEscalated`.
339    pub async fn escalate_to_project(
340        &self,
341        issue_id: Uuid,
342        project: &dyn ProjectPort,
343        sink: &dyn SupportEventSink,
344    ) -> Result<Uuid, SupportError> {
345        let issue = self
346            .issues
347            .find_escalation_candidate(&self.pool, issue_id)
348            .await?
349            .ok_or(SupportError::NotFound("issue"))?;
350        if let Some(pid) = issue.escalated_project_id {
351            return Ok(pid); // already escalated
352        }
353        if issue.status == "resolved" || issue.status == "closed" {
354            return Err(SupportError::InvalidState(
355                "a resolved/closed issue cannot be escalated",
356            ));
357        }
358        let customer_id: Uuid = issue.customer_id.ok_or(SupportError::Invalid(
359            "issue has no customer to open a project for".into(),
360        ))?;
361        // The delivery-project seam is a cross-module port whose sibling (backbone-project) is
362        // not tenant-agnostic: its Project rows still carry the legacy owner-company key. That
363        // key is the SIBLING's domain parameter (composition-installed tenancy, ADR-0029), not
364        // a fence of this module's — source it from the ambient org scope and fail closed when
365        // the request carries no company-anchored scope. The same key feeds the company-keyed
366        // outbox mirror row staged below.
367        let company_id = org_scope::current_org_scope()
368            .and_then(|s| s.legacy_company_id())
369            .ok_or(SupportError::NoCompanyScope)?;
370
371        // Open the delivery project (idempotent per issue on the project side).
372        let ack = project
373            .open_delivery_project(&ProjectFromIssue {
374                company_id,
375                issue_id,
376                customer_id,
377                subject: issue.subject,
378            })
379            .await
380            .map_err(|r| SupportError::ProjectRejected(r.code))?;
381
382        // Gate: claim the escalation exactly once, and stage the event in the SAME tx (outbox rollout plan,
383        // P2): backbone-project subscribes to IssueEscalated to open the delivery project, so a crash between
384        // the CAS and the in-proc publish must not drop it.
385        let mut tx = self.pool.begin().await?;
386        // Relay the ambient request scope onto this transaction so the CAS and the outbox stage
387        // run fenced under a decorated deployment, regardless of who drives the escalation.
388        if let Some(scope) = org_scope::current_org_scope() {
389            org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
390        }
391        let moved = self
392            .issues
393            .claim_escalation(&mut tx, issue_id, ack.project_id)
394            .await?;
395        if moved != 1 {
396            tx.rollback().await?;
397            let pid = self
398                .issues
399                .fetch_escalated_project_id(&self.pool, issue_id)
400                .await?;
401            return Ok(pid);
402        }
403        let event = SupportEvent::IssueEscalated(IssueEscalated {
404            issue_id,
405            project_id: ack.project_id,
406        });
407        let record = backbone_outbox::OutboxRecord::new(
408            "IssueEscalated",
409            "Issue",
410            issue_id.to_string(),
411            company_id,
412            serde_json::to_value(&event).map_err(|e| SupportError::Invalid(e.to_string()))?,
413            chrono::Utc::now(),
414        );
415        backbone_outbox::outbox::stage(&mut *tx, "support", &record)
416            .await
417            .map_err(|e| SupportError::Invalid(format!("outbox stage: {e}")))?;
418        tx.commit().await?;
419        sink.publish(&event);
420        Ok(ack.project_id)
421    }
422
423    /// File a warranty claim. Coverage is computed at file time: `is_under_warranty = claim_date <=
424    /// warranty_expiry` (an unknown/absent expiry is out of warranty). Emits `WarrantyClaimFiled`.
425    pub async fn file_warranty_claim(
426        &self,
427        c: NewWarrantyClaim,
428        now: DateTime<Utc>,
429        sink: &dyn SupportEventSink,
430    ) -> Result<Uuid, SupportError> {
431        let under = c.warranty_expiry.map(|e| now <= e).unwrap_or(false);
432        let id = Uuid::new_v4();
433        let claim = NewWarrantyClaimRow {
434            id,
435            customer_id: c.customer_id,
436            item_id: c.item_id,
437            serial_no: c.serial_no.as_deref(),
438            claim_date: now,
439            warranty_expiry: c.warranty_expiry,
440            is_under_warranty: under,
441            issue_id: c.issue_id,
442            description: c.description.as_deref(),
443        };
444        self.warranty_claims
445            .insert_claim(&self.pool, &claim)
446            .await?;
447        sink.publish(&SupportEvent::WarrantyClaimFiled(WarrantyClaimFiled {
448            claim_id: id,
449            is_under_warranty: under,
450        }));
451        Ok(id)
452    }
453
454    /// Adjudicate an open warranty claim (accept or reject) with a resolution note.
455    pub async fn resolve_warranty_claim(
456        &self,
457        claim_id: Uuid,
458        accepted: bool,
459        resolution: Option<String>,
460    ) -> Result<(), SupportError> {
461        let status = if accepted { "accepted" } else { "rejected" };
462        let moved = self
463            .warranty_claims
464            .adjudicate(&self.pool, claim_id, status, resolution.as_deref())
465            .await?;
466        if moved != 1 {
467            return Err(SupportError::InvalidState(
468                "only an open claim can be adjudicated",
469            ));
470        }
471        Ok(())
472    }
473}