backbone_support/infrastructure/persistence/issue_repository.rs
1//! Repository for Issue entities
2//!
3//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
4//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
5//! below are the home for every hand-written Issue SQL statement — the SLA clock's transitions
6//! (4-layer rule: services orchestrate and own the unit of work, repositories hold the SQL).
7//!
8//! Tenancy (ADR-0029): the SQL here carries no tenant key. The scoped-execute helpers ride the
9//! request-dedicated connection when the composing service bound one (its fence variables govern
10//! what the RLS layer accepts) and fall back to a plain pool execute otherwise.
11//!
12//! Thin newtype over `backbone_orm::GenericCrudRepository<Issue, backbone_orm::SoftDelete>`.
13//! All standard CRUD methods are available via `Deref`.
14
15use anyhow::Result;
16use chrono::{DateTime, Utc};
17use sqlx::{PgPool, Row};
18use uuid::Uuid;
19
20use backbone_orm::org_scope;
21
22use crate::domain::entity::Issue;
23
24/// Table name for Issue entities
25pub const TABLE_NAME: &str = "support.issues";
26
27/// Repository for Issue entities.
28///
29/// All standard CRUD, soft-delete, pagination, and bulk methods are
30/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
31pub struct IssueRepository(backbone_orm::GenericCrudRepository<Issue, backbone_orm::SoftDelete>);
32
33impl std::ops::Deref for IssueRepository {
34 type Target = backbone_orm::GenericCrudRepository<Issue, backbone_orm::SoftDelete>;
35 fn deref(&self) -> &Self::Target {
36 &self.0
37 }
38}
39
40impl IssueRepository {
41 /// Create a new repository instance.
42 pub fn new(pool: PgPool) -> Self {
43 Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
44 }
45}
46
47/// The exact row a raised ticket writes.
48///
49/// Mirrors the raw column shape rather than the `Issue` entity: `priority` is carried as a free string
50/// and cast at the DB (`$5::issue_priority`), so a bad priority fails as a DB error rather than a
51/// deserialize panic. The deadlines are already snapshotted by the caller.
52pub struct NewIssueRow<'a> {
53 pub id: Uuid,
54 pub customer_id: Option<Uuid>,
55 pub subject: &'a str,
56 pub description: Option<&'a str>,
57 pub priority: &'a str,
58 pub sla_id: Option<Uuid>,
59 pub opened_at: DateTime<Utc>,
60 pub response_by: Option<DateTime<Utc>>,
61 pub resolution_by: Option<DateTime<Utc>>,
62}
63
64/// The locked on-hold projection the resume math needs: when the clock stopped, and whether the first
65/// response leg is already closed.
66pub struct PausedIssueRow {
67 pub paused_at: DateTime<Utc>,
68 pub first_responded_at: Option<DateTime<Utc>>,
69}
70
71/// The pre-flight projection for resolve: the issue's live status (for the friendly
72/// on_hold/not-found errors — the SLA verdict itself is judged in `resolve`'s own UPDATE).
73pub struct IssueStateRow {
74 pub status: String,
75}
76
77/// The escalation candidate's projection — the customer the project opens for and the once-only
78/// gate (`escalated_project_id`).
79pub struct EscalationCandidateRow {
80 pub customer_id: Option<Uuid>,
81 pub subject: String,
82 pub status: String,
83 pub escalated_project_id: Option<Uuid>,
84}
85
86/// Hand-written Issue SQL. Lives here (not in the write service) per the module's 4-layer rule:
87/// services orchestrate and own the unit of work, repositories hold the SQL.
88impl IssueRepository {
89 /// Insert a raised ticket with its snapshotted deadlines.
90 ///
91 /// A write outside any transaction: takes the pool and runs the scoped helper so it rides the
92 /// request-dedicated connection when the composing service bound a scope — under a decorated
93 /// deployment the fence's WITH CHECK governs the row; with no scope bound this is a plain insert.
94 pub async fn insert_issue(
95 &self,
96 pool: &PgPool,
97 i: &NewIssueRow<'_>,
98 ) -> Result<(), sqlx::Error> {
99 org_scope::execute_scoped(
100 pool,
101 sqlx::query(
102 r#"INSERT INTO support.issues
103 (id, customer_id, subject, description, priority, sla_id, status,
104 agreement_status, opened_at, response_by, resolution_by, total_paused_mins)
105 VALUES ($1,$2,$3,$4,$5::issue_priority,$6,'open'::issue_status,
106 'first_response_due'::agreement_status,$7,$8,$9,0)"#,
107 )
108 .bind(i.id)
109 .bind(i.customer_id)
110 .bind(i.subject)
111 .bind(i.description)
112 .bind(i.priority)
113 .bind(i.sla_id)
114 .bind(i.opened_at)
115 .bind(i.response_by)
116 .bind(i.resolution_by),
117 )
118 .await?;
119 Ok(())
120 }
121
122 /// Record the first response and judge the response leg IN THE SAME gated UPDATE, against the row's
123 /// live (pause-adjusted) `response_by` — never a stale read. Returns rows affected (0 = the issue is
124 /// not awaiting a first response).
125 ///
126 /// ID-only: rides the request-dedicated connection when the composing service bound a scope, so a
127 /// row the deployment's fence excludes is simply not updated; with no scope bound this is a plain
128 /// update.
129 pub async fn record_first_response(
130 &self,
131 pool: &PgPool,
132 issue_id: Uuid,
133 now: DateTime<Utc>,
134 ) -> Result<u64, sqlx::Error> {
135 let moved = org_scope::execute_scoped(
136 pool,
137 sqlx::query(
138 r#"UPDATE support.issues
139 SET first_responded_at=$2, status='replied'::issue_status,
140 response_breached = (response_by IS NOT NULL AND $2 > response_by),
141 agreement_status = CASE WHEN response_by IS NOT NULL AND $2 > response_by
142 THEN 'failed'::agreement_status
143 ELSE 'resolution_due'::agreement_status END
144 WHERE id=$1 AND status='open'::issue_status AND (metadata->>'deleted_at') IS NULL"#,
145 )
146 .bind(issue_id).bind(now),
147 )
148 .await?;
149 Ok(moved.rows_affected())
150 }
151
152 /// Stop the SLA clock. Returns rows affected (0 = not an open/replied issue).
153 pub async fn pause(
154 &self,
155 pool: &PgPool,
156 issue_id: Uuid,
157 now: DateTime<Utc>,
158 ) -> Result<u64, sqlx::Error> {
159 let moved = org_scope::execute_scoped(
160 pool,
161 sqlx::query(
162 r#"UPDATE support.issues
163 SET status='on_hold'::issue_status, paused_at=$2
164 WHERE id=$1 AND status IN ('open','replied') AND (metadata->>'deleted_at') IS NULL"#,
165 )
166 .bind(issue_id).bind(now),
167 )
168 .await?;
169 Ok(moved.rows_affected())
170 }
171
172 /// Lock the on-hold row and read what the resume math needs. `Ok(None)` = the issue is not on hold.
173 ///
174 /// Takes the CALLER'S connection: this `FOR UPDATE` read and the deadline extension that follows are
175 /// one unit of work, and the lock must be held across both. The caller has already relayed the
176 /// ambient org scope onto that connection — don't re-bind here.
177 pub async fn lock_on_hold(
178 &self,
179 conn: &mut sqlx::PgConnection,
180 issue_id: Uuid,
181 ) -> Result<Option<PausedIssueRow>, sqlx::Error> {
182 let row = sqlx::query(
183 r#"SELECT paused_at, first_responded_at FROM support.issues
184 WHERE id=$1 AND status='on_hold'::issue_status FOR UPDATE"#,
185 )
186 .bind(issue_id)
187 .fetch_optional(conn)
188 .await?;
189 Ok(row.map(|r| PausedIssueRow {
190 paused_at: r.get("paused_at"),
191 first_responded_at: r.get("first_responded_at"),
192 }))
193 }
194
195 /// Restart the clock: add the paused span back to the outstanding deadlines so a hold never counts
196 /// against the SLA, and restore the running status. Extends the response deadline too, but only
197 /// while the first response is still outstanding.
198 ///
199 /// Takes the CALLER'S connection — it must be the one holding the `lock_on_hold` lock.
200 pub async fn apply_resume(
201 &self,
202 conn: &mut sqlx::PgConnection,
203 issue_id: Uuid,
204 paused_mins: i32,
205 running: &str,
206 ) -> Result<(), sqlx::Error> {
207 sqlx::query(
208 r#"UPDATE support.issues
209 SET status=$3::issue_status, paused_at=NULL,
210 total_paused_mins = total_paused_mins + $2,
211 resolution_by = resolution_by + ($2 * interval '1 minute'),
212 response_by = CASE WHEN first_responded_at IS NULL
213 THEN response_by + ($2 * interval '1 minute') ELSE response_by END
214 WHERE id=$1"#,
215 )
216 .bind(issue_id).bind(paused_mins).bind(running)
217 .execute(conn)
218 .await?;
219 Ok(())
220 }
221
222 /// Read an issue's live status. Only for the caller's friendly on_hold/not-found errors — the
223 /// SLA verdict is NOT computed from this snapshot (see `resolve`).
224 pub async fn find_state(
225 &self,
226 pool: &PgPool,
227 issue_id: Uuid,
228 ) -> Result<Option<IssueStateRow>, sqlx::Error> {
229 let row = org_scope::fetch_optional_row_scoped(
230 pool,
231 sqlx::query(
232 r#"SELECT status::text AS status FROM support.issues
233 WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
234 )
235 .bind(issue_id),
236 )
237 .await?;
238 Ok(row.map(|r| IssueStateRow {
239 status: r.get("status"),
240 }))
241 }
242
243 /// Resolve a ticket and judge the SLA IN THE SAME gated UPDATE, from the row's LIVE `resolution_by`.
244 /// Returns `Some(fulfilled)`, or `None` if the issue is no longer resolvable.
245 ///
246 /// Judging in the same statement as the transition is load-bearing: a concurrent pause+resume that
247 /// commits between a snapshot read and this write extends `resolution_by` and restores an
248 /// open/replied status, so a read-then-write split would stamp a stale verdict — a met SLA marked
249 /// `failed` (maturity council 2026-07-07). A ticket with no SLA (NULL resolution_by) is always
250 /// fulfilled. `fulfilled` requires BOTH legs met: the resolution is within `resolution_by`, AND the
251 /// response leg is not breached — either an already-recorded on-time response, or (none recorded
252 /// yet) resolving within `response_by` counts as responding in time (completeness council
253 /// 2026-07-07).
254 pub async fn resolve(
255 &self,
256 pool: &PgPool,
257 issue_id: Uuid,
258 now: DateTime<Utc>,
259 ) -> Result<Option<bool>, sqlx::Error> {
260 let row = org_scope::fetch_optional_row_scoped(
261 pool,
262 sqlx::query(
263 r#"UPDATE support.issues
264 SET status='resolved'::issue_status, resolved_at=$2,
265 agreement_status = CASE
266 WHEN (resolution_by IS NULL OR $2 <= resolution_by)
267 AND NOT response_breached
268 AND (first_responded_at IS NOT NULL OR response_by IS NULL OR $2 <= response_by)
269 THEN 'fulfilled'::agreement_status
270 ELSE 'failed'::agreement_status END
271 WHERE id=$1 AND status IN ('open','replied')
272 RETURNING (agreement_status = 'fulfilled'::agreement_status) AS fulfilled"#,
273 )
274 .bind(issue_id).bind(now),
275 )
276 .await?;
277 Ok(row.map(|r| r.get("fulfilled")))
278 }
279
280 /// Close a resolved ticket (terminal). Returns rows affected (0 = not a resolved issue).
281 pub async fn close(&self, pool: &PgPool, issue_id: Uuid) -> Result<u64, sqlx::Error> {
282 let moved = org_scope::execute_scoped(
283 pool,
284 sqlx::query(
285 r#"UPDATE support.issues SET status='closed'::issue_status
286 WHERE id=$1 AND status='resolved'::issue_status AND (metadata->>'deleted_at') IS NULL"#,
287 )
288 .bind(issue_id),
289 )
290 .await?;
291 Ok(moved.rows_affected())
292 }
293
294 /// Read what the escalation decision needs, including the once-only gate.
295 pub async fn find_escalation_candidate(
296 &self,
297 pool: &PgPool,
298 issue_id: Uuid,
299 ) -> Result<Option<EscalationCandidateRow>, sqlx::Error> {
300 let row = org_scope::fetch_optional_row_scoped(
301 pool,
302 sqlx::query(
303 r#"SELECT customer_id, subject, status::text AS status, escalated_project_id
304 FROM support.issues WHERE id=$1 AND (metadata->>'deleted_at') IS NULL"#,
305 )
306 .bind(issue_id),
307 )
308 .await?;
309 Ok(row.map(|r| EscalationCandidateRow {
310 customer_id: r.get("customer_id"),
311 subject: r.get("subject"),
312 status: r.get("status"),
313 escalated_project_id: r.get("escalated_project_id"),
314 }))
315 }
316
317 /// Claim the escalation exactly once (CAS on `escalated_project_id IS NULL`). Returns rows affected
318 /// (0 = a concurrent escalation won; the caller re-reads the winner's project id).
319 ///
320 /// Takes the CALLER'S connection so the claim and the outbox stage commit as one unit — backbone-project
321 /// subscribes to IssueEscalated, so a crash between the CAS and the publish must not drop it. The
322 /// caller has already relayed the ambient org scope onto that connection — don't re-bind here.
323 pub async fn claim_escalation(
324 &self,
325 conn: &mut sqlx::PgConnection,
326 issue_id: Uuid,
327 project_id: Uuid,
328 ) -> Result<u64, sqlx::Error> {
329 let moved = sqlx::query(
330 r#"UPDATE support.issues SET escalated_project_id=$2
331 WHERE id=$1 AND escalated_project_id IS NULL"#,
332 )
333 .bind(issue_id)
334 .bind(project_id)
335 .execute(conn)
336 .await?;
337 Ok(moved.rows_affected())
338 }
339
340 /// Re-read the winner's project id after a losing escalation CAS.
341 ///
342 /// A read outside the (rolled-back) transaction: rides the request-dedicated connection when the
343 /// composing service bound a scope, plain pool otherwise.
344 pub async fn fetch_escalated_project_id(
345 &self,
346 pool: &PgPool,
347 issue_id: Uuid,
348 ) -> Result<Uuid, sqlx::Error> {
349 let row = org_scope::fetch_optional_row_scoped(
350 pool,
351 sqlx::query("SELECT escalated_project_id FROM support.issues WHERE id=$1")
352 .bind(issue_id),
353 )
354 .await?;
355 row.map(|r| r.get::<Uuid, _>("escalated_project_id"))
356 .ok_or(sqlx::Error::RowNotFound)
357 }
358}
359
360backbone_core::impl_crud_repository!(IssueRepository, Issue, soft_delete);