backbone-maintenance 0.7.21

Minimal Backbone Framework module skeleton
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Repository for MaintenanceRequest entities
//!
//! Originally generated by metaphor-schema; now **user-owned** — this exact path is declared under
//! `user_owned` in `metaphor.codegen.yaml`, so the generator skips it wholesale. The custom methods
//! below hold the hand-written MaintenanceRequest SQL (4-layer rule: services orchestrate, repos hold
//! SQL): the create insert, the transition CAS, and the successor-spawn claim + insert of the
//! clone-on-done engine.
//!
//! Thin newtype over `backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>`.
//! All standard CRUD methods are available via `Deref`.

use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use sqlx::{PgPool, Row};
use uuid::Uuid;

use backbone_orm::{company_scope, org_scope};

use crate::domain::entity::MaintenanceRequest;

/// Table name for MaintenanceRequest entities
pub const TABLE_NAME: &str = "maintenance.maintenance_requests";

/// Repository for MaintenanceRequest entities.
///
/// All standard CRUD, soft-delete, pagination, and bulk methods are
/// provided automatically via `Deref` to `backbone_orm::GenericCrudRepository`.
pub struct MaintenanceRequestRepository(
    backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>,
);

impl std::ops::Deref for MaintenanceRequestRepository {
    type Target = backbone_orm::GenericCrudRepository<MaintenanceRequest, backbone_orm::SoftDelete>;
    fn deref(&self) -> &Self::Target { &self.0 }
}

impl MaintenanceRequestRepository {
    /// Create a new repository instance.
    pub fn new(pool: PgPool) -> Self {
        Self(backbone_orm::GenericCrudRepository::new(pool, TABLE_NAME))
    }
}

/// The exact row a request create (or successor spawn) writes.
///
/// Mirrors the raw column shape rather than the `MaintenanceRequest` entity: the enum columns bind as
/// `&str` and are cast at the DB (`$n::request_kanban_state` etc.), so a bad value fails as a DB error
/// rather than a deserialize panic.
pub struct NewRequestRow<'a> {
    pub id: Uuid,
    pub name: &'a str,
    pub description: Option<&'a str>,
    pub schedule_date: Option<DateTime<Utc>>,
    pub schedule_end: Option<DateTime<Utc>>,
    pub duration: Decimal,
    pub owner_user_id: Option<Uuid>,
    pub user_id: Option<Uuid>,
    pub asset_id: Option<Uuid>,
    pub stage_id: Uuid,
    pub kanban_state: &'a str,
    pub priority: &'a str,
    pub maintenance_type: &'a str,
    pub recurring: bool,
    pub repeat_interval: i32,
    pub repeat_unit: &'a str,
    pub repeat_type: &'a str,
    pub repeat_until: Option<NaiveDate>,
    /// Set only on the clone-on-done successor (the backref to its source).
    pub successor_of_request_id: Option<Uuid>,
}

/// A request's full state as the transition path reads it — `stage_done` joins in the CURRENT stage's
/// done flag (the canonical closed flag the engine keys off).
pub struct RequestTransitionRow {
    pub name: String,
    pub description: Option<String>,
    pub schedule_date: Option<DateTime<Utc>>,
    pub schedule_end: Option<DateTime<Utc>>,
    pub close_date: Option<NaiveDate>,
    pub duration: Decimal,
    pub owner_user_id: Option<Uuid>,
    pub user_id: Option<Uuid>,
    pub asset_id: Option<Uuid>,
    pub stage_id: Uuid,
    pub kanban_state: String,
    pub priority: String,
    pub maintenance_type: String,
    pub recurring: bool,
    pub repeat_interval: i32,
    pub repeat_unit: String,
    pub repeat_type: String,
    pub repeat_until: Option<NaiveDate>,
    pub successor_request_id: Option<Uuid>,
    /// Whether the request's CURRENT stage is a done (closing) stage.
    pub stage_done: bool,
}

/// A stage row as the request path reads it (visible through the shared_blank policy).
pub struct StageRefRow {
    pub id: Uuid,
    pub name: String,
    pub sequence: i32,
    pub done: bool,
}

/// The recurrence/descriptive fields `update_request` may change. `None` leaves the column unchanged
/// (COALESCE at the DB). The lifecycle columns — stage_id, kanban_state, close_date, the successor
/// markers — are deliberately absent: they belong to the transition verb and the spawn engine.
pub struct RequestFieldUpdates<'a> {
    pub name: Option<&'a str>,
    pub description: Option<&'a str>,
    pub schedule_date: Option<DateTime<Utc>>,
    pub schedule_end: Option<DateTime<Utc>>,
    pub duration: Option<Decimal>,
    pub owner_user_id: Option<Uuid>,
    pub user_id: Option<Uuid>,
    pub asset_id: Option<Uuid>,
    pub priority: Option<&'a str>,
    pub maintenance_type: Option<&'a str>,
    pub recurring: Option<bool>,
    pub repeat_interval: Option<i32>,
    pub repeat_unit: Option<&'a str>,
    pub repeat_type: Option<&'a str>,
    pub repeat_until: Option<NaiveDate>,
}

impl<'a> RequestFieldUpdates<'a> {
    /// True when no field is set — callers can skip the UPDATE entirely.
    pub fn is_empty(&self) -> bool {
        self.name.is_none()
            && self.description.is_none()
            && self.schedule_date.is_none()
            && self.schedule_end.is_none()
            && self.duration.is_none()
            && self.owner_user_id.is_none()
            && self.user_id.is_none()
            && self.asset_id.is_none()
            && self.priority.is_none()
            && self.maintenance_type.is_none()
            && self.recurring.is_none()
            && self.repeat_interval.is_none()
            && self.repeat_unit.is_none()
            && self.repeat_type.is_none()
            && self.repeat_until.is_none()
    }
}

/// Hand-written MaintenanceRequest SQL. Lives here (not in the write service) per the module's 4-layer
/// rule: services orchestrate and own the unit of work, repositories hold the SQL.
impl MaintenanceRequestRepository {
    /// Insert a request on the caller's connection (the transition transaction uses this for the
    /// successor spawn). The caller has already relayed the ambient org scope onto the connection
    /// so the INSERT passes the fence's WITH CHECK.
    pub async fn insert_request_on(
        &self,
        conn: &mut sqlx::PgConnection,
        r: &NewRequestRow<'_>,
    ) -> Result<(), sqlx::Error> {
        sqlx::query(
            r#"INSERT INTO maintenance.maintenance_requests
                 (id, name, description, schedule_date, schedule_end, duration,
                  owner_user_id, user_id, asset_id, stage_id, kanban_state, priority,
                  maintenance_type, recurring, repeat_interval, repeat_unit, repeat_type,
                  repeat_until, successor_of_request_id)
               VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::request_kanban_state,
                       $12::request_priority,$13::maintenance_type,$14,$15,$16::repeat_unit,
                       $17::repeat_type,$18,$19)"#,
        )
        .bind(r.id).bind(r.name).bind(r.description)
        .bind(r.schedule_date).bind(r.schedule_end).bind(r.duration)
        .bind(r.owner_user_id).bind(r.user_id).bind(r.asset_id).bind(r.stage_id)
        .bind(r.kanban_state).bind(r.priority).bind(r.maintenance_type).bind(r.recurring)
        .bind(r.repeat_interval).bind(r.repeat_unit).bind(r.repeat_type).bind(r.repeat_until)
        .bind(r.successor_of_request_id)
        .execute(conn)
        .await?;
        Ok(())
    }

    /// Insert a request outside a transaction. Runs on the pool via `execute_scoped` under the
    /// ambient org scope, so the INSERT passes the fence's WITH CHECK (the decorator's fill
    /// stamps the acting unit when the org column rides the composed shape).
    pub async fn insert_request(
        &self,
        pool: &PgPool,
        r: &NewRequestRow<'_>,
    ) -> Result<(), sqlx::Error> {
        org_scope::execute_scoped(
            pool,
            sqlx::query(
                r#"INSERT INTO maintenance.maintenance_requests
                     (id, name, description, schedule_date, schedule_end, duration,
                      owner_user_id, user_id, asset_id, stage_id, kanban_state, priority,
                      maintenance_type, recurring, repeat_interval, repeat_unit, repeat_type,
                      repeat_until, successor_of_request_id)
                   VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::request_kanban_state,
                           $12::request_priority,$13::maintenance_type,$14,$15,$16::repeat_unit,
                           $17::repeat_type,$18,$19)"#,
            )
            .bind(r.id).bind(r.name).bind(r.description)
            .bind(r.schedule_date).bind(r.schedule_end).bind(r.duration)
            .bind(r.owner_user_id).bind(r.user_id).bind(r.asset_id).bind(r.stage_id)
            .bind(r.kanban_state).bind(r.priority).bind(r.maintenance_type).bind(r.recurring)
            .bind(r.repeat_interval).bind(r.repeat_unit).bind(r.repeat_type).bind(r.repeat_until)
            .bind(r.successor_of_request_id),
        )
        .await?;
        Ok(())
    }

    /// The full state the transition path needs (current stage's done flag joined in).
    /// `Ok(None)` when the request is absent/soft-deleted. ID-only: the caller's scope decides
    /// visibility, so another company's request simply is not found.
    pub async fn fetch_for_transition(
        &self,
        pool: &PgPool,
        request_id: Uuid,
    ) -> Result<Option<RequestTransitionRow>, sqlx::Error> {
        let row = company_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT r.name, r.description, r.schedule_date, r.schedule_end,
                          r.close_date, r.duration, r.owner_user_id, r.user_id, r.asset_id,
                          r.stage_id, r.kanban_state::text AS kanban_state,
                          r.priority::text AS priority, r.maintenance_type::text AS maintenance_type,
                          r.recurring, r.repeat_interval, r.repeat_unit::text AS repeat_unit,
                          r.repeat_type::text AS repeat_type, r.repeat_until,
                          r.successor_request_id, COALESCE(s.done, FALSE) AS stage_done
                     FROM maintenance.maintenance_requests r
                     LEFT JOIN maintenance.maintenance_stages s ON s.id = r.stage_id
                    WHERE r.id = $1 AND (r.metadata->>'deleted_at') IS NULL"#,
            )
            .bind(request_id),
        )
        .await?;
        Ok(row.map(|r| RequestTransitionRow {
            name: r.get("name"),
            description: r.get("description"),
            schedule_date: r.get("schedule_date"),
            schedule_end: r.get("schedule_end"),
            close_date: r.get("close_date"),
            duration: r.get("duration"),
            owner_user_id: r.get("owner_user_id"),
            user_id: r.get("user_id"),
            asset_id: r.get("asset_id"),
            stage_id: r.get("stage_id"),
            kanban_state: r.get("kanban_state"),
            priority: r.get("priority"),
            maintenance_type: r.get("maintenance_type"),
            recurring: r.get("recurring"),
            repeat_interval: r.get("repeat_interval"),
            repeat_unit: r.get("repeat_unit"),
            repeat_type: r.get("repeat_type"),
            repeat_until: r.get("repeat_until"),
            successor_request_id: r.get("successor_request_id"),
            stage_done: r.get("stage_done"),
        }))
    }

    /// A stage row. The shared stage set is root-anchored in the composed shape, so every
    /// entitled scope sees it through the scope union. `Ok(None)` when absent or soft-deleted.
    pub async fn fetch_stage(
        &self,
        pool: &PgPool,
        stage_id: Uuid,
    ) -> Result<Option<StageRefRow>, sqlx::Error> {
        let row = company_scope::fetch_optional_row_scoped(
            pool,
            sqlx::query(
                r#"SELECT id, name, sequence, done
                     FROM maintenance.maintenance_stages
                    WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(stage_id),
        )
        .await?;
        Ok(row.map(|r| StageRefRow {
            id: r.get("id"),
            name: r.get("name"),
            sequence: r.get("sequence"),
            done: r.get("done"),
        }))
    }

    /// The first visible stage by (sequence, id) — where a new request (and a spawned successor)
    /// starts. `Ok(None)` when the instance has no visible stage at all.
    pub async fn fetch_first_stage_id(&self, pool: &PgPool) -> Result<Option<Uuid>, sqlx::Error> {
        company_scope::fetch_optional_scalar_scoped(
            pool,
            sqlx::query_scalar(
                r#"SELECT id FROM maintenance.maintenance_stages
                    WHERE (metadata->>'deleted_at') IS NULL
                    ORDER BY sequence, id LIMIT 1"#,
            ),
        )
        .await
    }

    /// Conn-bound twin of [`Self::fetch_first_stage_id`] for use INSIDE the transition transaction:
    /// reads through the caller's already-bound connection (same scope as the CAS), instead of a
    /// fresh pool connection that would fail closed under an app-role session.
    pub async fn fetch_first_stage_id_on(
        &self,
        conn: &mut sqlx::PgConnection,
    ) -> Result<Option<Uuid>, sqlx::Error> {
        let row = sqlx::query_scalar::<_, Uuid>(
            r#"SELECT id FROM maintenance.maintenance_stages
                WHERE (metadata->>'deleted_at') IS NULL
                ORDER BY sequence, id LIMIT 1"#,
        )
        .fetch_optional(conn)
        .await?;
        Ok(row)
    }

    /// The stage-transition CAS: move the request to `target_stage_id` (optionally setting
    /// `kanban_state` in the same statement) iff it is live and not already there. The G-MT5 trigger
    /// applies the kanban reset + close_date sync on this same statement. Returns the rows affected —
    /// 0 means the request was already at the target (or raced), and the caller must re-read.
    ///
    /// Takes the CALLER'S connection so this, the successor claim/insert, and the outbox stage commit
    /// as one unit. The caller has already bound the company and set the managed-transition marker.
    pub async fn transition_cas(
        &self,
        conn: &mut sqlx::PgConnection,
        request_id: Uuid,
        target_stage_id: Uuid,
        kanban_override: Option<&str>,
    ) -> Result<u64, sqlx::Error> {
        let r = match kanban_override {
            Some(k) => sqlx::query(
                r#"UPDATE maintenance.maintenance_requests
                   SET stage_id = $2, kanban_state = $3::request_kanban_state
                   WHERE id = $1 AND stage_id IS DISTINCT FROM $2
                     AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(request_id)
            .bind(target_stage_id)
            .bind(k)
            .execute(conn)
            .await?,
            None => sqlx::query(
                r#"UPDATE maintenance.maintenance_requests
                   SET stage_id = $2
                   WHERE id = $1 AND stage_id IS DISTINCT FROM $2
                     AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(request_id)
            .bind(target_stage_id)
            .execute(conn)
            .await?,
        };
        Ok(r.rows_affected())
    }

    /// Claim the successor slot: stamp `successor_request_id` iff it is still NULL. Returns the rows
    /// affected — 0 means a spawn already happened (raced or repeat) and the caller must skip the
    /// INSERT. Runs BEFORE the successor INSERT on the same connection, so the successor exists iff
    /// the close committed (at-most-once per source).
    pub async fn claim_successor_slot(
        &self,
        conn: &mut sqlx::PgConnection,
        request_id: Uuid,
        successor_id: Uuid,
    ) -> Result<u64, sqlx::Error> {
        let r = sqlx::query(
            r#"UPDATE maintenance.maintenance_requests SET successor_request_id = $2
               WHERE id = $1 AND successor_request_id IS NULL
                 AND (metadata->>'deleted_at') IS NULL"#,
        )
        .bind(request_id)
        .bind(successor_id)
        .execute(conn)
        .await?;
        Ok(r.rows_affected())
    }

    /// Apply a partial field update (COALESCE: `None` leaves the column unchanged). The lifecycle
    /// columns are not reachable here by construction. Returns the rows affected; 0 = not found.
    pub async fn update_request_fields(
        &self,
        pool: &PgPool,
        request_id: Uuid,
        u: &RequestFieldUpdates<'_>,
    ) -> Result<u64, sqlx::Error> {
        let r = org_scope::execute_scoped(
            pool,
            sqlx::query(
                r#"UPDATE maintenance.maintenance_requests SET
                       name            = COALESCE($2, name),
                       description     = COALESCE($3, description),
                       schedule_date   = COALESCE($4::timestamptz, schedule_date),
                       schedule_end    = COALESCE($5::timestamptz, schedule_end),
                       duration        = COALESCE($6, duration),
                       owner_user_id   = COALESCE($7, owner_user_id),
                       user_id         = COALESCE($8, user_id),
                       asset_id        = COALESCE($9, asset_id),
                       priority        = COALESCE($10::request_priority, priority),
                       maintenance_type = COALESCE($11::maintenance_type, maintenance_type),
                       recurring       = COALESCE($12, recurring),
                       repeat_interval = COALESCE($13, repeat_interval),
                       repeat_unit     = COALESCE($14::repeat_unit, repeat_unit),
                       repeat_type     = COALESCE($15::repeat_type, repeat_type),
                       repeat_until    = COALESCE($16, repeat_until)
                   WHERE id = $1 AND (metadata->>'deleted_at') IS NULL"#,
            )
            .bind(request_id)
            .bind(u.name)
            .bind(u.description)
            .bind(u.schedule_date)
            .bind(u.schedule_end)
            .bind(u.duration)
            .bind(u.owner_user_id)
            .bind(u.user_id)
            .bind(u.asset_id)
            .bind(u.priority)
            .bind(u.maintenance_type)
            .bind(u.recurring)
            .bind(u.repeat_interval)
            .bind(u.repeat_unit)
            .bind(u.repeat_type)
            .bind(u.repeat_until),
        )
        .await?;
        Ok(r.rows_affected())
    }
}

backbone_core::impl_crud_repository!(MaintenanceRequestRepository, MaintenanceRequest, soft_delete);