Skip to main content

backbone_maintenance/
maintenance_module_ext.rs

1//! Hand-authored extension on [`crate::MaintenanceModule`] — the safe default route composer.
2//!
3//! **Why a separate file:** the generator rewrites `src/lib.rs`'s generated `impl` region on every
4//! `metaphor make`, so anything hand-added there is clobbered. This file is never touched by the
5//! generator (declared in `metaphor.codegen.yaml` `user_owned`, wired via a `// <<< CUSTOM` `pub mod`
6//! in `lib.rs`), so the delivery survives regen. Rust permits multiple `impl MaintenanceModule` blocks,
7//! so the method lives here while the struct, builder, and generated `all_crud_routes()` stay in
8//! `lib.rs`.
9//!
10//! **The invariant-bypass closure (council 2026-08-02, rec #1):** the generated
11//! [`crate::MaintenanceModule::all_crud_routes`] mounts unguarded generic CRUD — including a PATCH
12//! whose DTO exposes `status` / `parts_cost` / `total_cost` / `journal_id` / `accounting_post_id` — on
13//! `MaintenanceVisit`, so a caller can flip a visit to `completed` with no inventory issue and no
14//! journal, routing around every invariant [`crate::MaintenanceWriteService`] enforces. The same
15//! closure covers `MaintenanceRequest`: its generated PATCH would expose `stage_id`,
16//! `kanban_state`, `close_date`, and the successor markers — the exact columns the
17//! clone-on-done engine owns. The schema offers no attribute to restrict those PATCHes and
18//! `all_crud_routes` is generated wholesale, so the closure is delivered here, at the composer: use
19//! [`Self::read_only_routes`] as the production surface; reserve `all_crud_routes()` / `routes()` for
20//! trusted/admin/seeding contexts. The validated write path (wiring `MaintenanceWriteService` and
21//! `MaintenanceRequestWriteService` into the module) is a separate, later step.
22
23use axum::Router;
24
25impl crate::MaintenanceModule {
26    /// The safe default route surface: full CRUD on the two benign masters (`MaintenanceSchedule`,
27    /// `MaintenanceStage`), and **read-only** on the engine-owned tables (`MaintenanceVisit`,
28    /// `MaintenanceVisitPart`, `MaintenanceRequest`) whose state must only change through
29    /// [`crate::MaintenanceWriteService`] / [`crate::MaintenanceRequestWriteService`].
30    ///
31    /// Stage CRUD is mounted in full because stages are company-agnostic master data (shared across
32    /// the fence): creating/reordering them is an administrative act with no engine invariants, and
33    /// referential safety is enforced in the DB (RESTRICT on the request FK, plus the soft-delete
34    /// guard raising `maintenance_stage_in_use` while any live request still sits on the stage).
35    ///
36    /// Use this in place of the generated [`crate::MaintenanceModule::all_crud_routes`], which mounts
37    /// unguarded writes on the visit/part/request tables. Direct mirror of
38    /// `backbone-asset::AssetsModule::read_only_routes` (CRUD on the benign master, read-only on the
39    /// engine-owned financial tables).
40    pub fn read_only_routes(&self) -> Router {
41        use crate::presentation::http::{
42            create_maintenance_request_read_routes, create_maintenance_schedule_routes,
43            create_maintenance_stage_routes, create_maintenance_visit_part_read_routes,
44            create_maintenance_visit_read_routes,
45        };
46
47        Router::new()
48            .merge(create_maintenance_schedule_routes(
49                self.maintenance_schedule_service.clone(),
50            ))
51            .merge(create_maintenance_stage_routes(
52                self.maintenance_stage_service.clone(),
53            ))
54            .merge(create_maintenance_request_read_routes(
55                self.maintenance_request_service.clone(),
56            ))
57            .merge(create_maintenance_visit_read_routes(
58                self.maintenance_visit_service.clone(),
59            ))
60            .merge(create_maintenance_visit_part_read_routes(
61                self.maintenance_visit_part_service.clone(),
62            ))
63    }
64
65    /// The published read contract for sibling modules — the now-implemented
66    /// [`crate::exports::MaintenanceQueryService`] over the module's DTOs. Mirror of
67    /// `backbone-asset::AssetsModule::query_service`.
68    pub fn query_service(&self) -> std::sync::Arc<dyn crate::exports::MaintenanceQueryService> {
69        self.query.clone()
70    }
71
72    /// The validated, GL/inventory-backed visit write surface — plan/start/add-part/complete/cancel.
73    /// These are the only verbs that may change a visit's state; compose the production surface as
74    /// `module.read_only_routes().merge(module.lifecycle_routes())` (reads + validated writes). Do
75    /// NOT merge with [`crate::MaintenanceModule::all_crud_routes`] — both mount `POST /maintenance_visits`.
76    ///
77    /// Requires a `GlPostSink` and an `InventoryPort` supplied via the builder
78    /// ([`crate::MaintenanceModuleBuilder::with_gl_sink`] / `with_inventory_port`); missing either
79    /// is a wiring error and panics at startup. The event sink defaults to `LoggingSink`.
80    pub fn lifecycle_routes(&self) -> Router {
81        use crate::presentation::http::{
82            create_maintenance_lifecycle_routes, create_maintenance_request_lifecycle_routes,
83            MaintenanceLifecycleState, MaintenanceRequestLifecycleState,
84        };
85
86        let gl = self.gl_sink.clone().expect(
87            "MaintenanceModule::lifecycle_routes() requires a GlPostSink — pass one via \
88             MaintenanceModuleBuilder::with_gl_sink(...)",
89        );
90        let inventory = self.inventory_port.clone().expect(
91            "MaintenanceModule::lifecycle_routes() requires an InventoryPort — pass one via \
92             MaintenanceModuleBuilder::with_inventory_port(...)",
93        );
94        create_maintenance_lifecycle_routes(MaintenanceLifecycleState {
95            write_svc: self.write_svc.clone(),
96            inventory,
97            gl,
98            event_sink: self.event_sink.clone(),
99        })
100        .merge(create_maintenance_request_lifecycle_routes(
101            MaintenanceRequestLifecycleState {
102                write_svc: self.request_write_svc.clone(),
103                event_sink: self.event_sink.clone(),
104            },
105        ))
106    }
107}