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    /// No longer returned: escalation echoes the ambient company, or nil, instead of requiring
47    /// one (ADR-0029). Kept so a caller's match still compiles; removed in the next breaking
48    /// release.
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 legacy tenancy twin echo (ADR-0029): the port and the outbox record still carry a
362        // `company_id`, which gets the ambient org scope's company when the composing service
363        // bound one and nil otherwise. Nothing keys a statement on it; backbone-project takes no
364        // company, so escalation never needs one.
365        let company_id = org_scope::current_org_scope()
366            .and_then(|s| s.legacy_company_id())
367            .unwrap_or(Uuid::nil());
368
369        // Open the delivery project (idempotent per issue on the project side).
370        let ack = project
371            .open_delivery_project(&ProjectFromIssue {
372                company_id,
373                issue_id,
374                customer_id,
375                subject: issue.subject,
376            })
377            .await
378            .map_err(|r| SupportError::ProjectRejected(r.code))?;
379
380        // Gate: claim the escalation exactly once, and stage the event in the SAME tx (outbox rollout plan,
381        // P2): backbone-project subscribes to IssueEscalated to open the delivery project, so a crash between
382        // the CAS and the in-proc publish must not drop it.
383        let mut tx = self.pool.begin().await?;
384        // Relay the ambient request scope onto this transaction so the CAS and the outbox stage
385        // run fenced under a decorated deployment, regardless of who drives the escalation.
386        if let Some(scope) = org_scope::current_org_scope() {
387            org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
388        }
389        let moved = self
390            .issues
391            .claim_escalation(&mut tx, issue_id, ack.project_id)
392            .await?;
393        if moved != 1 {
394            tx.rollback().await?;
395            let pid = self
396                .issues
397                .fetch_escalated_project_id(&self.pool, issue_id)
398                .await?;
399            return Ok(pid);
400        }
401        let event = SupportEvent::IssueEscalated(IssueEscalated {
402            issue_id,
403            project_id: ack.project_id,
404        });
405        let record = backbone_outbox::OutboxRecord::new(
406            "IssueEscalated",
407            "Issue",
408            issue_id.to_string(),
409            company_id,
410            serde_json::to_value(&event).map_err(|e| SupportError::Invalid(e.to_string()))?,
411            chrono::Utc::now(),
412        );
413        backbone_outbox::outbox::stage(&mut *tx, "support", &record)
414            .await
415            .map_err(|e| SupportError::Invalid(format!("outbox stage: {e}")))?;
416        tx.commit().await?;
417        sink.publish(&event);
418        Ok(ack.project_id)
419    }
420
421    /// File a warranty claim. Coverage is computed at file time: `is_under_warranty = claim_date <=
422    /// warranty_expiry` (an unknown/absent expiry is out of warranty). Emits `WarrantyClaimFiled`.
423    pub async fn file_warranty_claim(
424        &self,
425        c: NewWarrantyClaim,
426        now: DateTime<Utc>,
427        sink: &dyn SupportEventSink,
428    ) -> Result<Uuid, SupportError> {
429        let under = c.warranty_expiry.map(|e| now <= e).unwrap_or(false);
430        let id = Uuid::new_v4();
431        let claim = NewWarrantyClaimRow {
432            id,
433            customer_id: c.customer_id,
434            item_id: c.item_id,
435            serial_no: c.serial_no.as_deref(),
436            claim_date: now,
437            warranty_expiry: c.warranty_expiry,
438            is_under_warranty: under,
439            issue_id: c.issue_id,
440            description: c.description.as_deref(),
441        };
442        self.warranty_claims
443            .insert_claim(&self.pool, &claim)
444            .await?;
445        sink.publish(&SupportEvent::WarrantyClaimFiled(WarrantyClaimFiled {
446            claim_id: id,
447            is_under_warranty: under,
448        }));
449        Ok(id)
450    }
451
452    /// Adjudicate an open warranty claim (accept or reject) with a resolution note.
453    pub async fn resolve_warranty_claim(
454        &self,
455        claim_id: Uuid,
456        accepted: bool,
457        resolution: Option<String>,
458    ) -> Result<(), SupportError> {
459        let status = if accepted { "accepted" } else { "rejected" };
460        let moved = self
461            .warranty_claims
462            .adjudicate(&self.pool, claim_id, status, resolution.as_deref())
463            .await?;
464        if moved != 1 {
465            return Err(SupportError::InvalidState(
466                "only an open claim can be adjudicated",
467            ));
468        }
469        Ok(())
470    }
471}