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}