Skip to main content

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);