1use 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 #[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, 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, 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 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 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 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 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 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 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 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 pub async fn resume_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
249 let mut tx = self.pool.begin().await?;
250 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 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 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 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 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 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); }
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 let company_id = org_scope::current_org_scope()
366 .and_then(|s| s.legacy_company_id())
367 .unwrap_or(Uuid::nil());
368
369 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 let mut tx = self.pool.begin().await?;
384 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 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 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}