use backbone_orm::org_scope;
use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;
use uuid::Uuid;
use crate::infrastructure::persistence::{
IssueRepository, NewIssueRow, NewSlaPriorityRow, NewSlaRow, NewWarrantyClaimRow,
ServiceLevelAgreementRepository, ServiceLevelPriorityRepository, WarrantyClaimRepository,
};
use super::support_events::*;
use super::support_ports::*;
#[derive(Debug, thiserror::Error)]
pub enum SupportError {
#[error("db: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(&'static str),
#[error("invalid state: {0}")]
InvalidState(&'static str),
#[error("invalid input: {0}")]
Invalid(String),
#[error("project rejected: {0}")]
ProjectRejected(String),
#[error("no company in the ambient org scope — escalation requires one (composition-installed tenancy, ADR-0029)")]
NoCompanyScope,
}
pub struct NewSlaPriority {
pub priority: String, pub response_time_mins: i32,
pub resolution_time_mins: i32,
}
pub struct NewSla {
pub name: String,
pub is_default: bool,
pub priorities: Vec<NewSlaPriority>,
}
pub struct NewIssue {
pub customer_id: Option<Uuid>,
pub subject: String,
pub description: Option<String>,
pub priority: String, pub sla_id: Option<Uuid>,
}
pub struct NewWarrantyClaim {
pub customer_id: Option<Uuid>,
pub item_id: Uuid,
pub serial_no: Option<String>,
pub warranty_expiry: Option<DateTime<Utc>>,
pub issue_id: Option<Uuid>,
pub description: Option<String>,
}
pub struct SupportWriteService {
pool: PgPool,
slas: ServiceLevelAgreementRepository,
sla_priorities: ServiceLevelPriorityRepository,
issues: IssueRepository,
warranty_claims: WarrantyClaimRepository,
}
impl SupportWriteService {
pub fn new(pool: PgPool) -> Self {
let slas = ServiceLevelAgreementRepository::new(pool.clone());
let sla_priorities = ServiceLevelPriorityRepository::new(pool.clone());
let issues = IssueRepository::new(pool.clone());
let warranty_claims = WarrantyClaimRepository::new(pool.clone());
Self {
pool,
slas,
sla_priorities,
issues,
warranty_claims,
}
}
pub async fn create_sla(&self, s: NewSla) -> Result<Uuid, SupportError> {
if s.name.trim().is_empty() {
return Err(SupportError::Invalid("SLA needs a name".into()));
}
if s.priorities.is_empty() {
return Err(SupportError::Invalid(
"an SLA needs at least one priority target".into(),
));
}
for p in &s.priorities {
if p.response_time_mins < 0 || p.resolution_time_mins < 0 {
return Err(SupportError::Invalid(
"SLA times must be non-negative".into(),
));
}
if p.resolution_time_mins < p.response_time_mins {
return Err(SupportError::Invalid(
"resolution target must be >= response target".into(),
));
}
}
let id = Uuid::new_v4();
let mut tx = self.pool.begin().await?;
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
}
self.slas
.insert_sla(
&mut tx,
&NewSlaRow {
id,
name: &s.name,
is_default: s.is_default,
},
)
.await?;
for p in &s.priorities {
self.sla_priorities
.insert_priority(
&mut tx,
&NewSlaPriorityRow {
id: Uuid::new_v4(),
sla_id: id,
priority: &p.priority,
response_time_mins: p.response_time_mins,
resolution_time_mins: p.resolution_time_mins,
},
)
.await?;
}
tx.commit().await?;
Ok(id)
}
pub async fn raise_issue(&self, i: NewIssue, now: DateTime<Utc>) -> Result<Uuid, SupportError> {
if i.subject.trim().is_empty() {
return Err(SupportError::Invalid("issue needs a subject".into()));
}
let sla_id: Option<Uuid> = match i.sla_id {
Some(id) => Some(id),
None => self.slas.find_default_id(&self.pool).await?,
};
let (mut response_by, mut resolution_by) = (None, None);
if let Some(sid) = sla_id {
let target = self
.sla_priorities
.find_target(&self.pool, sid, &i.priority)
.await?
.ok_or(SupportError::Invalid(
"SLA has no target for this priority".into(),
))?;
response_by = Some(now + Duration::minutes(target.response_time_mins as i64));
resolution_by = Some(now + Duration::minutes(target.resolution_time_mins as i64));
}
let id = Uuid::new_v4();
self.issues
.insert_issue(
&self.pool,
&NewIssueRow {
id,
customer_id: i.customer_id,
subject: &i.subject,
description: i.description.as_deref(),
priority: &i.priority,
sla_id,
opened_at: now,
response_by,
resolution_by,
},
)
.await?;
Ok(id)
}
pub async fn record_first_response(
&self,
issue_id: Uuid,
now: DateTime<Utc>,
) -> Result<(), SupportError> {
let moved = self
.issues
.record_first_response(&self.pool, issue_id, now)
.await?;
if moved != 1 {
return Err(SupportError::InvalidState(
"issue is not awaiting a first response",
));
}
Ok(())
}
pub async fn pause_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
let moved = self.issues.pause(&self.pool, issue_id, now).await?;
if moved != 1 {
return Err(SupportError::InvalidState(
"only an open/replied issue can be paused",
));
}
Ok(())
}
pub async fn resume_sla(&self, issue_id: Uuid, now: DateTime<Utc>) -> Result<(), SupportError> {
let mut tx = self.pool.begin().await?;
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
}
let row = self.issues.lock_on_hold(&mut tx, issue_id).await?;
let row = match row {
Some(r) => r,
None => {
tx.rollback().await?;
return Err(SupportError::InvalidState("issue is not on hold"));
}
};
let paused_mins = (now - row.paused_at).num_minutes().max(0);
let running = if row.first_responded_at.is_some() {
"replied"
} else {
"open"
};
self.issues
.apply_resume(&mut tx, issue_id, paused_mins as i32, running)
.await?;
tx.commit().await?;
Ok(())
}
pub async fn resolve_issue(
&self,
issue_id: Uuid,
now: DateTime<Utc>,
sink: &dyn SupportEventSink,
) -> Result<bool, SupportError> {
let issue = self
.issues
.find_state(&self.pool, issue_id)
.await?
.ok_or(SupportError::NotFound("issue"))?;
if issue.status == "on_hold" {
return Err(SupportError::InvalidState(
"resume the issue before resolving",
));
}
if issue.status != "open" && issue.status != "replied" {
return Err(SupportError::InvalidState(
"only an open/replied issue can be resolved",
));
}
let fulfilled = self
.issues
.resolve(&self.pool, issue_id, now)
.await?
.ok_or(SupportError::InvalidState("issue is no longer resolvable"))?;
sink.publish(&SupportEvent::IssueResolved(IssueResolved {
issue_id,
fulfilled,
}));
Ok(fulfilled)
}
pub async fn close_issue(&self, issue_id: Uuid) -> Result<(), SupportError> {
let moved = self.issues.close(&self.pool, issue_id).await?;
if moved != 1 {
return Err(SupportError::InvalidState(
"only a resolved issue can be closed",
));
}
Ok(())
}
pub async fn escalate_to_project(
&self,
issue_id: Uuid,
project: &dyn ProjectPort,
sink: &dyn SupportEventSink,
) -> Result<Uuid, SupportError> {
let issue = self
.issues
.find_escalation_candidate(&self.pool, issue_id)
.await?
.ok_or(SupportError::NotFound("issue"))?;
if let Some(pid) = issue.escalated_project_id {
return Ok(pid); }
if issue.status == "resolved" || issue.status == "closed" {
return Err(SupportError::InvalidState(
"a resolved/closed issue cannot be escalated",
));
}
let customer_id: Uuid = issue.customer_id.ok_or(SupportError::Invalid(
"issue has no customer to open a project for".into(),
))?;
let company_id = org_scope::current_org_scope()
.and_then(|s| s.legacy_company_id())
.ok_or(SupportError::NoCompanyScope)?;
let ack = project
.open_delivery_project(&ProjectFromIssue {
company_id,
issue_id,
customer_id,
subject: issue.subject,
})
.await
.map_err(|r| SupportError::ProjectRejected(r.code))?;
let mut tx = self.pool.begin().await?;
if let Some(scope) = org_scope::current_org_scope() {
org_scope::bind_org_scope_on(&mut *tx, &scope).await?;
}
let moved = self
.issues
.claim_escalation(&mut tx, issue_id, ack.project_id)
.await?;
if moved != 1 {
tx.rollback().await?;
let pid = self
.issues
.fetch_escalated_project_id(&self.pool, issue_id)
.await?;
return Ok(pid);
}
let event = SupportEvent::IssueEscalated(IssueEscalated {
issue_id,
project_id: ack.project_id,
});
let record = backbone_outbox::OutboxRecord::new(
"IssueEscalated",
"Issue",
issue_id.to_string(),
company_id,
serde_json::to_value(&event).map_err(|e| SupportError::Invalid(e.to_string()))?,
chrono::Utc::now(),
);
backbone_outbox::outbox::stage(&mut *tx, "support", &record)
.await
.map_err(|e| SupportError::Invalid(format!("outbox stage: {e}")))?;
tx.commit().await?;
sink.publish(&event);
Ok(ack.project_id)
}
pub async fn file_warranty_claim(
&self,
c: NewWarrantyClaim,
now: DateTime<Utc>,
sink: &dyn SupportEventSink,
) -> Result<Uuid, SupportError> {
let under = c.warranty_expiry.map(|e| now <= e).unwrap_or(false);
let id = Uuid::new_v4();
let claim = NewWarrantyClaimRow {
id,
customer_id: c.customer_id,
item_id: c.item_id,
serial_no: c.serial_no.as_deref(),
claim_date: now,
warranty_expiry: c.warranty_expiry,
is_under_warranty: under,
issue_id: c.issue_id,
description: c.description.as_deref(),
};
self.warranty_claims
.insert_claim(&self.pool, &claim)
.await?;
sink.publish(&SupportEvent::WarrantyClaimFiled(WarrantyClaimFiled {
claim_id: id,
is_under_warranty: under,
}));
Ok(id)
}
pub async fn resolve_warranty_claim(
&self,
claim_id: Uuid,
accepted: bool,
resolution: Option<String>,
) -> Result<(), SupportError> {
let status = if accepted { "accepted" } else { "rejected" };
let moved = self
.warranty_claims
.adjudicate(&self.pool, claim_id, status, resolution.as_deref())
.await?;
if moved != 1 {
return Err(SupportError::InvalidState(
"only an open claim can be adjudicated",
));
}
Ok(())
}
}