Skip to main content

backbone_maintenance/infrastructure/persistence/
maintenance_request_repository.rs

1//! Repository for MaintenanceRequest 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 hold the hand-written MaintenanceRequest SQL (4-layer rule: services orchestrate, repos hold
6//! SQL): the create insert, the transition CAS, and the successor-spawn claim + insert of the
7//! clone-on-done engine.
8//!
9//! Thin newtype over `backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>`.
10//! All standard CRUD methods are available via `Deref`.
11
12use chrono::{DateTime, NaiveDate, Utc};
13use rust_decimal::Decimal;
14use sqlx::{PgPool, Row};
15use uuid::Uuid;
16
17use backbone_orm::{company_scope, org_scope};
18
19use crate::domain::entity::MaintenanceRequest;
20
21/// Table name for MaintenanceRequest entities
22pub const TABLE_NAME: &str = "maintenance.maintenance_requests";
23
24/// Repository for MaintenanceRequest entities.
25///
26/// All standard CRUD, soft-delete, pagination, and bulk methods are
27/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
28pub struct MaintenanceRequestRepository(
29    backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>,
30);
31
32impl std::ops::Deref for MaintenanceRequestRepository {
33    type Target = backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>;
34    fn deref(&self) -> &Self::Target { &self.0 }
35}
36
37impl MaintenanceRequestRepository {
38    /// Create a new repository instance.
39    pub fn new(pool: PgPool) -> Self {
40        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
41    }
42}
43
44/// The exact row a request create (or successor spawn) writes.
45///
46/// Mirrors the raw column shape rather than the `MaintenanceRequest` entity: the enum columns bind as
47/// `&str` and are cast at the DB (`$n::request_kanban_state` etc.), so a bad value fails as a DB error
48/// rather than a deserialize panic.
49pub struct NewRequestRow<'a> {
50    pub id: Uuid,
51    pub name: &'a str,
52    pub description: Option<&'a str>,
53    pub schedule_date: Option<DateTime<Utc>>,
54    pub schedule_end: Option<DateTime<Utc>>,
55    pub duration: Decimal,
56    pub owner_user_id: Option<Uuid>,
57    pub user_id: Option<Uuid>,
58    pub asset_id: Option<Uuid>,
59    pub stage_id: Uuid,
60    pub kanban_state: &'a str,
61    pub priority: &'a str,
62    pub maintenance_type: &'a str,
63    pub recurring: bool,
64    pub repeat_interval: i32,
65    pub repeat_unit: &'a str,
66    pub repeat_type: &'a str,
67    pub repeat_until: Option<NaiveDate>,
68    /// Set only on the clone-on-done successor (the backref to its source).
69    pub successor_of_request_id: Option<Uuid>,
70}
71
72/// A request's full state as the transition path reads it — `stage_done` joins in the CURRENT stage's
73/// done flag (the canonical closed flag the engine keys off).
74pub struct RequestTransitionRow {
75    pub name: String,
76    pub description: Option<String>,
77    pub schedule_date: Option<DateTime<Utc>>,
78    pub schedule_end: Option<DateTime<Utc>>,
79    pub close_date: Option<NaiveDate>,
80    pub duration: Decimal,
81    pub owner_user_id: Option<Uuid>,
82    pub user_id: Option<Uuid>,
83    pub asset_id: Option<Uuid>,
84    pub stage_id: Uuid,
85    pub kanban_state: String,
86    pub priority: String,
87    pub maintenance_type: String,
88    pub recurring: bool,
89    pub repeat_interval: i32,
90    pub repeat_unit: String,
91    pub repeat_type: String,
92    pub repeat_until: Option<NaiveDate>,
93    pub successor_request_id: Option<Uuid>,
94    /// Whether the request's CURRENT stage is a done (closing) stage.
95    pub stage_done: bool,
96}
97
98/// A stage row as the request path reads it (visible through the shared_blank policy).
99pub struct StageRefRow {
100    pub id: Uuid,
101    pub name: String,
102    pub sequence: i32,
103    pub done: bool,
104}
105
106/// The recurrence/descriptive fields `update_request` may change. `None` leaves the column unchanged
107/// (COALESCE at the DB). The lifecycle columns — stage_id, kanban_state, close_date, the successor
108/// markers — are deliberately absent: they belong to the transition verb and the spawn engine.
109pub struct RequestFieldUpdates<'a> {
110    pub name: Option<&'a str>,
111    pub description: Option<&'a str>,
112    pub schedule_date: Option<DateTime<Utc>>,
113    pub schedule_end: Option<DateTime<Utc>>,
114    pub duration: Option<Decimal>,
115    pub owner_user_id: Option<Uuid>,
116    pub user_id: Option<Uuid>,
117    pub asset_id: Option<Uuid>,
118    pub priority: Option<&'a str>,
119    pub maintenance_type: Option<&'a str>,
120    pub recurring: Option<bool>,
121    pub repeat_interval: Option<i32>,
122    pub repeat_unit: Option<&'a str>,
123    pub repeat_type: Option<&'a str>,
124    pub repeat_until: Option<NaiveDate>,
125}
126
127impl<'a> RequestFieldUpdates<'a> {
128    /// True when no field is set — callers can skip the UPDATE entirely.
129    pub fn is_empty(&self) -> bool {
130        self.name.is_none()
131            && self.description.is_none()
132            && self.schedule_date.is_none()
133            && self.schedule_end.is_none()
134            && self.duration.is_none()
135            && self.owner_user_id.is_none()
136            && self.user_id.is_none()
137            && self.asset_id.is_none()
138            && self.priority.is_none()
139            && self.maintenance_type.is_none()
140            && self.recurring.is_none()
141            && self.repeat_interval.is_none()
142            && self.repeat_unit.is_none()
143            && self.repeat_type.is_none()
144            && self.repeat_until.is_none()
145    }
146}
147
148/// Hand-written MaintenanceRequest SQL. Lives here (not in the write service) per the module's 4-layer
149/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
150impl MaintenanceRequestRepository {
151    /// Insert a request on the caller's connection (the transition transaction uses this for the
152    /// successor spawn). The caller has already relayed the ambient org scope onto the connection
153    /// so the INSERT passes the fence's WITH CHECK.
154    pub async fn insert_request_on(
155        &self,
156        conn: &mut sqlx::PgConnection,
157        r: &NewRequestRow<'_>,
158    ) -> Result<(), sqlx::Error> {
159        sqlx::query(
160            r#"INSERT INTO maintenance.maintenance_requests
161                 (id, name, description, schedule_date, schedule_end, duration,
162                  owner_user_id, user_id, asset_id, stage_id, kanban_state, priority,
163                  maintenance_type, recurring, repeat_interval, repeat_unit, repeat_type,
164                  repeat_until, successor_of_request_id)
165               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::request_kanban_state,
166                       $12::request_priority,$13::maintenance_type,$14,$15,$16::repeat_unit,
167                       $17::repeat_type,$18,$19)"#,
168        )
169        .bind(r.id).bind(r.name).bind(r.description)
170        .bind(r.schedule_date).bind(r.schedule_end).bind(r.duration)
171        .bind(r.owner_user_id).bind(r.user_id).bind(r.asset_id).bind(r.stage_id)
172        .bind(r.kanban_state).bind(r.priority).bind(r.maintenance_type).bind(r.recurring)
173        .bind(r.repeat_interval).bind(r.repeat_unit).bind(r.repeat_type).bind(r.repeat_until)
174        .bind(r.successor_of_request_id)
175        .execute(conn)
176        .await?;
177        Ok(())
178    }
179
180    /// Insert a request outside a transaction. Runs on the pool via `execute_scoped` under the
181    /// ambient org scope, so the INSERT passes the fence's WITH CHECK (the decorator's fill
182    /// stamps the acting unit when the org column rides the composed shape).
183    pub async fn insert_request(
184        &self,
185        pool: &PgPool,
186        r: &NewRequestRow<'_>,
187    ) -> Result<(), sqlx::Error> {
188        org_scope::execute_scoped(
189            pool,
190            sqlx::query(
191                r#"INSERT INTO maintenance.maintenance_requests
192                     (id, name, description, schedule_date, schedule_end, duration,
193                      owner_user_id, user_id, asset_id, stage_id, kanban_state, priority,
194                      maintenance_type, recurring, repeat_interval, repeat_unit, repeat_type,
195                      repeat_until, successor_of_request_id)
196                   VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::request_kanban_state,
197                           $12::request_priority,$13::maintenance_type,$14,$15,$16::repeat_unit,
198                           $17::repeat_type,$18,$19)"#,
199            )
200            .bind(r.id).bind(r.name).bind(r.description)
201            .bind(r.schedule_date).bind(r.schedule_end).bind(r.duration)
202            .bind(r.owner_user_id).bind(r.user_id).bind(r.asset_id).bind(r.stage_id)
203            .bind(r.kanban_state).bind(r.priority).bind(r.maintenance_type).bind(r.recurring)
204            .bind(r.repeat_interval).bind(r.repeat_unit).bind(r.repeat_type).bind(r.repeat_until)
205            .bind(r.successor_of_request_id),
206        )
207        .await?;
208        Ok(())
209    }
210
211    /// The full state the transition path needs (current stage's done flag joined in).
212    /// `Ok(None)` when the request is absent/soft-deleted. ID-only: the caller's scope decides
213    /// visibility, so another company's request simply is not found.
214    pub async fn fetch_for_transition(
215        &self,
216        pool: &PgPool,
217        request_id: Uuid,
218    ) -> Result<Option<RequestTransitionRow>, sqlx::Error> {
219        let row = company_scope::fetch_optional_row_scoped(
220            pool,
221            sqlx::query(
222                r#"SELECT r.name, r.description, r.schedule_date, r.schedule_end,
223                          r.close_date, r.duration, r.owner_user_id, r.user_id, r.asset_id,
224                          r.stage_id, r.kanban_state::text AS kanban_state,
225                          r.priority::text AS priority, r.maintenance_type::text AS maintenance_type,
226                          r.recurring, r.repeat_interval, r.repeat_unit::text AS repeat_unit,
227                          r.repeat_type::text AS repeat_type, r.repeat_until,
228                          r.successor_request_id, COALESCE(s.done, FALSE) AS stage_done
229                     FROM maintenance.maintenance_requests r
230                     LEFT JOIN maintenance.maintenance_stages s ON s.id = r.stage_id
231                    WHERE r.id = $1 AND (r.metadata->>'deleted_at') IS NULL"#,
232            )
233            .bind(request_id),
234        )
235        .await?;
236        Ok(row.map(|r| RequestTransitionRow {
237            name: r.get("name"),
238            description: r.get("description"),
239            schedule_date: r.get("schedule_date"),
240            schedule_end: r.get("schedule_end"),
241            close_date: r.get("close_date"),
242            duration: r.get("duration"),
243            owner_user_id: r.get("owner_user_id"),
244            user_id: r.get("user_id"),
245            asset_id: r.get("asset_id"),
246            stage_id: r.get("stage_id"),
247            kanban_state: r.get("kanban_state"),
248            priority: r.get("priority"),
249            maintenance_type: r.get("maintenance_type"),
250            recurring: r.get("recurring"),
251            repeat_interval: r.get("repeat_interval"),
252            repeat_unit: r.get("repeat_unit"),
253            repeat_type: r.get("repeat_type"),
254            repeat_until: r.get("repeat_until"),
255            successor_request_id: r.get("successor_request_id"),
256            stage_done: r.get("stage_done"),
257        }))
258    }
259
260    /// A stage row. The shared stage set is root-anchored in the composed shape, so every
261    /// entitled scope sees it through the scope union. `Ok(None)` when absent or soft-deleted.
262    pub async fn fetch_stage(
263        &self,
264        pool: &PgPool,
265        stage_id: Uuid,
266    ) -> Result<Option<StageRefRow>, sqlx::Error> {
267        let row = company_scope::fetch_optional_row_scoped(
268            pool,
269            sqlx::query(
270                r#"SELECT id, name, sequence, done
271                     FROM maintenance.maintenance_stages
272                    WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
273            )
274            .bind(stage_id),
275        )
276        .await?;
277        Ok(row.map(|r| StageRefRow {
278            id: r.get("id"),
279            name: r.get("name"),
280            sequence: r.get("sequence"),
281            done: r.get("done"),
282        }))
283    }
284
285    /// The first visible stage by (sequence, id) — where a new request (and a spawned successor)
286    /// starts. `Ok(None)` when the instance has no visible stage at all.
287    pub async fn fetch_first_stage_id(&self, pool: &PgPool) -> Result<Option<Uuid>, sqlx::Error> {
288        company_scope::fetch_optional_scalar_scoped(
289            pool,
290            sqlx::query_scalar(
291                r#"SELECT id FROM maintenance.maintenance_stages
292                    WHERE (metadata->>'deleted_at') IS NULL
293                    ORDER BY sequence, id LIMIT 1"#,
294            ),
295        )
296        .await
297    }
298
299    /// Conn-bound twin of [`Self::fetch_first_stage_id`] for use INSIDE the transition transaction:
300    /// reads through the caller's already-bound connection (same scope as the CAS), instead of a
301    /// fresh pool connection that would fail closed under an app-role session.
302    pub async fn fetch_first_stage_id_on(
303        &self,
304        conn: &mut sqlx::PgConnection,
305    ) -> Result<Option<Uuid>, sqlx::Error> {
306        let row = sqlx::query_scalar::<_, Uuid>(
307            r#"SELECT id FROM maintenance.maintenance_stages
308                WHERE (metadata->>'deleted_at') IS NULL
309                ORDER BY sequence, id LIMIT 1"#,
310        )
311        .fetch_optional(conn)
312        .await?;
313        Ok(row)
314    }
315
316    /// The stage-transition CAS: move the request to `target_stage_id` (optionally setting
317    /// `kanban_state` in the same statement) iff it is live and not already there. The G-MT5 trigger
318    /// applies the kanban reset + close_date sync on this same statement. Returns the rows affected —
319    /// 0 means the request was already at the target (or raced), and the caller must re-read.
320    ///
321    /// Takes the CALLER'S connection so this, the successor claim/insert, and the outbox stage commit
322    /// as one unit. The caller has already bound the company and set the managed-transition marker.
323    pub async fn transition_cas(
324        &self,
325        conn: &mut sqlx::PgConnection,
326        request_id: Uuid,
327        target_stage_id: Uuid,
328        kanban_override: Option<&str>,
329    ) -> Result<u64, sqlx::Error> {
330        let r = match kanban_override {
331            Some(k) => sqlx::query(
332                r#"UPDATE maintenance.maintenance_requests
333                   SET stage_id = $2, kanban_state = $3::request_kanban_state
334                   WHERE id = $1 AND stage_id IS DISTINCT FROM $2
335                     AND (metadata->>'deleted_at') IS NULL"#,
336            )
337            .bind(request_id)
338            .bind(target_stage_id)
339            .bind(k)
340            .execute(conn)
341            .await?,
342            None => sqlx::query(
343                r#"UPDATE maintenance.maintenance_requests
344                   SET stage_id = $2
345                   WHERE id = $1 AND stage_id IS DISTINCT FROM $2
346                     AND (metadata->>'deleted_at') IS NULL"#,
347            )
348            .bind(request_id)
349            .bind(target_stage_id)
350            .execute(conn)
351            .await?,
352        };
353        Ok(r.rows_affected())
354    }
355
356    /// Claim the successor slot: stamp `successor_request_id` iff it is still NULL. Returns the rows
357    /// affected — 0 means a spawn already happened (raced or repeat) and the caller must skip the
358    /// INSERT. Runs BEFORE the successor INSERT on the same connection, so the successor exists iff
359    /// the close committed (at-most-once per source).
360    pub async fn claim_successor_slot(
361        &self,
362        conn: &mut sqlx::PgConnection,
363        request_id: Uuid,
364        successor_id: Uuid,
365    ) -> Result<u64, sqlx::Error> {
366        let r = sqlx::query(
367            r#"UPDATE maintenance.maintenance_requests SET successor_request_id = $2
368               WHERE id = $1 AND successor_request_id IS NULL
369                 AND (metadata->>'deleted_at') IS NULL"#,
370        )
371        .bind(request_id)
372        .bind(successor_id)
373        .execute(conn)
374        .await?;
375        Ok(r.rows_affected())
376    }
377
378    /// Apply a partial field update (COALESCE: `None` leaves the column unchanged). The lifecycle
379    /// columns are not reachable here by construction. Returns the rows affected; 0 = not found.
380    pub async fn update_request_fields(
381        &self,
382        pool: &PgPool,
383        request_id: Uuid,
384        u: &RequestFieldUpdates<'_>,
385    ) -> Result<u64, sqlx::Error> {
386        let r = org_scope::execute_scoped(
387            pool,
388            sqlx::query(
389                r#"UPDATE maintenance.maintenance_requests SET
390                       name            = COALESCE($2, name),
391                       description     = COALESCE($3, description),
392                       schedule_date   = COALESCE($4::timestamptz, schedule_date),
393                       schedule_end    = COALESCE($5::timestamptz, schedule_end),
394                       duration        = COALESCE($6, duration),
395                       owner_user_id   = COALESCE($7, owner_user_id),
396                       user_id         = COALESCE($8, user_id),
397                       asset_id        = COALESCE($9, asset_id),
398                       priority        = COALESCE($10::request_priority, priority),
399                       maintenance_type = COALESCE($11::maintenance_type, maintenance_type),
400                       recurring       = COALESCE($12, recurring),
401                       repeat_interval = COALESCE($13, repeat_interval),
402                       repeat_unit     = COALESCE($14::repeat_unit, repeat_unit),
403                       repeat_type     = COALESCE($15::repeat_type, repeat_type),
404                       repeat_until    = COALESCE($16, repeat_until)
405                   WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
406            )
407            .bind(request_id)
408            .bind(u.name)
409            .bind(u.description)
410            .bind(u.schedule_date)
411            .bind(u.schedule_end)
412            .bind(u.duration)
413            .bind(u.owner_user_id)
414            .bind(u.user_id)
415            .bind(u.asset_id)
416            .bind(u.priority)
417            .bind(u.maintenance_type)
418            .bind(u.recurring)
419            .bind(u.repeat_interval)
420            .bind(u.repeat_unit)
421            .bind(u.repeat_type)
422            .bind(u.repeat_until),
423        )
424        .await?;
425        Ok(r.rows_affected())
426    }
427}
428
429backbone_core::impl_crud_repository!(MaintenanceRequestRepository, MaintenanceRequest, soft_delete);