Skip to main content

backbone_maintenance/presentation/http/
maintenance_request_lifecycle_handler.rs

1//! The validated maintenance request lifecycle write surface (hand-authored, user-owned).
2//!
3//! Three verbs mirror the write service — create, recurrence-field update, and the stage
4//! `transition` (the only sanctioned door for stage changes; closing a preventive recurring request
5//! through it spawns the successor inside the same transaction). Requests deliberately get NO
6//! generic write surface in the production composer (see
7//! [`crate::MaintenanceModule::read_only_routes`]): a bare PATCH could flip `stage_id` outside the
8//! verb — the G-MT5 trigger backstops even that path, but the verb is the sanctioned door.
9//!
10//! Mounted via [`crate::MaintenanceModule::lifecycle_routes`]; the production surface composes as
11//! `module.read_only_routes().merge(module.lifecycle_routes())` (reads + validated writes).
12//!
13//! NOTE: tenancy rides the ambient org scope (ADR-0029) — the composing service binds the acting
14//! org unit per request and the write path scopes every verb through the caller-scoped helpers.
15//! No verb reads a tenant value from the request body.
16
17use std::sync::Arc;
18
19use axum::{
20    extract::{Path, State},
21    http::StatusCode,
22    response::IntoResponse,
23    routing::post,
24    Json, Router,
25};
26use chrono::{DateTime, NaiveDate, Utc};
27use rust_decimal::Decimal;
28use serde::Deserialize;
29use serde_json::{json, Value};
30use uuid::Uuid;
31
32use crate::application::service::{
33    MaintenanceEventSink, MaintenanceRequestUpdate, MaintenanceRequestWriteService,
34    NewMaintenanceRequest, RequestWriteError as WriteError,
35};
36use crate::domain::entity::{MaintenanceType, RequestKanbanState, RequestPriority, RepeatType, RepeatUnit};
37
38/// Shared state for the request lifecycle routes: the write service plus the event sink the
39/// transition verb publishes through. `Clone` (everything is behind an `Arc`), so axum can hand a
40/// copy to each request.
41#[derive(Clone)]
42pub struct MaintenanceRequestLifecycleState {
43    pub write_svc: Arc<MaintenanceRequestWriteService>,
44    pub event_sink: Arc<dyn MaintenanceEventSink>,
45}
46
47/// Local wrapper around the engine `RequestWriteError` so we can impl axum's `IntoResponse` (foreign
48/// trait + foreign type → orphan rule). Maps each domain failure to a stable HTTP code + contract
49/// error string; every guard has a typed 4xx.
50pub struct MaintenanceRequestApiError(pub WriteError);
51
52impl IntoResponse for MaintenanceRequestApiError {
53    fn into_response(self) -> axum::response::Response {
54        let msg = self.0.to_string();
55        let (status, code) = match self.0 {
56            WriteError::NotFound(_) => (StatusCode::NOT_FOUND, "MAINTENANCE_REQUEST_NOT_FOUND"),
57            // G-MT2
58            WriteError::ScheduleEndBeforeStart => (
59                StatusCode::UNPROCESSABLE_ENTITY,
60                "MAINTENANCE_REQUEST_SCHEDULE_END_BEFORE_START",
61            ),
62            // G-MT3
63            WriteError::RepeatIntervalBelowOne => (
64                StatusCode::UNPROCESSABLE_ENTITY,
65                "MAINTENANCE_REQUEST_REPEAT_INTERVAL_BELOW_ONE",
66            ),
67            // G-MT7
68            WriteError::RepeatUntilMissing => (
69                StatusCode::UNPROCESSABLE_ENTITY,
70                "MAINTENANCE_REQUEST_REPEAT_UNTIL_MISSING",
71            ),
72            // G-MT8
73            WriteError::CorrectiveCannotRecur => (
74                StatusCode::UNPROCESSABLE_ENTITY,
75                "MAINTENANCE_REQUEST_CORRECTIVE_CANNOT_RECUR",
76            ),
77            WriteError::InvalidState(_) => (StatusCode::CONFLICT, "MAINTENANCE_REQUEST_INVALID_STATE"),
78            WriteError::Invalid(_) => (StatusCode::BAD_REQUEST, "MAINTENANCE_REQUEST_INVALID_INPUT"),
79            WriteError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "MAINTENANCE_REQUEST_DATABASE_ERROR"),
80        };
81        (status, Json(json!({ "success": false, "error": code, "message": msg }))).into_response()
82    }
83}
84
85// ---------------------------------------------------------------------------
86// Request bodies
87// ---------------------------------------------------------------------------
88
89/// File a request. `stageId` is optional — omitted means the first visible stage.
90#[derive(Debug, Deserialize)]
91#[serde(rename_all = "camelCase")]
92pub struct CreateMaintenanceRequestBody {
93    pub name: String,
94    #[serde(default)]
95    pub description: Option<String>,
96    #[serde(default)]
97    pub schedule_date: Option<DateTime<Utc>>,
98    #[serde(default)]
99    pub schedule_end: Option<DateTime<Utc>>,
100    #[serde(default)]
101    pub duration: Decimal,
102    #[serde(default)]
103    pub owner_user_id: Option<Uuid>,
104    #[serde(default)]
105    pub user_id: Option<Uuid>,
106    #[serde(default)]
107    pub asset_id: Option<Uuid>,
108    #[serde(default)]
109    pub stage_id: Option<Uuid>,
110    #[serde(default)]
111    pub kanban_state: RequestKanbanState,
112    #[serde(default)]
113    pub priority: RequestPriority,
114    #[serde(default = "default_maintenance_type")]
115    pub maintenance_type: MaintenanceType,
116    #[serde(default)]
117    pub recurring: bool,
118    #[serde(default = "default_repeat_interval")]
119    pub repeat_interval: i32,
120    #[serde(default)]
121    pub repeat_unit: RepeatUnit,
122    #[serde(default)]
123    pub repeat_type: RepeatType,
124    #[serde(default)]
125    pub repeat_until: Option<NaiveDate>,
126}
127
128fn default_maintenance_type() -> MaintenanceType {
129    MaintenanceType::Corrective
130}
131
132fn default_repeat_interval() -> i32 {
133    1
134}
135
136/// Move a request to another stage. `kanbanState` optionally sets the per-stage sub-state in the
137/// same statement; omitted, it auto-resets to `normal` on the stage change.
138#[derive(Debug, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct TransitionRequestBody {
141    pub target_stage_id: Uuid,
142    #[serde(default)]
143    pub kanban_state: Option<RequestKanbanState>,
144}
145
146/// Change a request's descriptive/recurrence fields. `None` (absent) leaves a field unchanged; the
147/// lifecycle columns are not reachable through this body.
148#[derive(Debug, Deserialize)]
149#[serde(rename_all = "camelCase")]
150pub struct UpdateMaintenanceRequestBody {
151    #[serde(default)]
152    pub name: Option<String>,
153    #[serde(default)]
154    pub description: Option<String>,
155    #[serde(default)]
156    pub schedule_date: Option<DateTime<Utc>>,
157    #[serde(default)]
158    pub schedule_end: Option<DateTime<Utc>>,
159    #[serde(default)]
160    pub duration: Option<Decimal>,
161    #[serde(default)]
162    pub owner_user_id: Option<Uuid>,
163    #[serde(default)]
164    pub user_id: Option<Uuid>,
165    #[serde(default)]
166    pub asset_id: Option<Uuid>,
167    #[serde(default)]
168    pub priority: Option<RequestPriority>,
169    #[serde(default)]
170    pub maintenance_type: Option<MaintenanceType>,
171    #[serde(default)]
172    pub recurring: Option<bool>,
173    #[serde(default)]
174    pub repeat_interval: Option<i32>,
175    #[serde(default)]
176    pub repeat_unit: Option<RepeatUnit>,
177    #[serde(default)]
178    pub repeat_type: Option<RepeatType>,
179    #[serde(default)]
180    pub repeat_until: Option<NaiveDate>,
181}
182
183// ---------------------------------------------------------------------------
184// Handlers
185// ---------------------------------------------------------------------------
186
187/// File a maintenance request (stage defaults to the first visible stage).
188pub async fn create_request(
189    State(st): State<MaintenanceRequestLifecycleState>,
190    Json(req): Json<CreateMaintenanceRequestBody>,
191) -> Result<(StatusCode, Json<Value>), MaintenanceRequestApiError> {
192    let id = st
193        .write_svc
194        .create_request(NewMaintenanceRequest {
195            name: req.name,
196            description: req.description,
197            schedule_date: req.schedule_date,
198            schedule_end: req.schedule_end,
199            duration: req.duration,
200            owner_user_id: req.owner_user_id,
201            user_id: req.user_id,
202            asset_id: req.asset_id,
203            stage_id: req.stage_id,
204            kanban_state: req.kanban_state,
205            priority: req.priority,
206            maintenance_type: req.maintenance_type,
207            recurring: req.recurring,
208            repeat_interval: req.repeat_interval,
209            repeat_unit: req.repeat_unit,
210            repeat_type: req.repeat_type,
211            repeat_until: req.repeat_until,
212        })
213        .await
214        .map_err(MaintenanceRequestApiError)?;
215    Ok((
216        StatusCode::CREATED,
217        Json(json!({ "success": true, "data": { "id": id } })),
218    ))
219}
220
221/// Move a request to another stage — the one sanctioned door for stage changes. Closing a
222/// preventive recurring request through this verb spawns its successor in the same transaction.
223pub async fn transition_request(
224    State(st): State<MaintenanceRequestLifecycleState>,
225    Path(id): Path<Uuid>,
226    Json(req): Json<TransitionRequestBody>,
227) -> Result<(StatusCode, Json<Value>), MaintenanceRequestApiError> {
228    let outcome = st
229        .write_svc
230        .transition_request(id, req.target_stage_id, req.kanban_state, st.event_sink.as_ref())
231        .await
232        .map_err(MaintenanceRequestApiError)?;
233    Ok((
234        StatusCode::OK,
235        Json(json!({
236            "success": true,
237            "data": {
238                "request_id": outcome.request_id,
239                "from_stage_id": outcome.from_stage_id,
240                "to_stage_id": outcome.to_stage_id,
241                "close_date": outcome.close_date,
242                "spawned_successor_id": outcome.spawned_successor_id,
243                "already": outcome.already,
244            }
245        })),
246    ))
247}
248
249/// Change a request's descriptive/recurrence fields (the widened G-MT2 check runs on the merged
250/// old-row + update view; the table CHECK backstops raw writers).
251pub async fn update_request(
252    State(st): State<MaintenanceRequestLifecycleState>,
253    Path(id): Path<Uuid>,
254    Json(req): Json<UpdateMaintenanceRequestBody>,
255) -> Result<(StatusCode, Json<Value>), MaintenanceRequestApiError> {
256    st.write_svc
257        .update_request(
258            id,
259            MaintenanceRequestUpdate {
260                name: req.name,
261                description: req.description,
262                schedule_date: req.schedule_date,
263                schedule_end: req.schedule_end,
264                duration: req.duration,
265                owner_user_id: req.owner_user_id,
266                user_id: req.user_id,
267                asset_id: req.asset_id,
268                priority: req.priority,
269                maintenance_type: req.maintenance_type,
270                recurring: req.recurring,
271                repeat_interval: req.repeat_interval,
272                repeat_unit: req.repeat_unit,
273                repeat_type: req.repeat_type,
274                repeat_until: req.repeat_until,
275            },
276        )
277        .await
278        .map_err(MaintenanceRequestApiError)?;
279    Ok((StatusCode::OK, Json(json!({ "success": true }))))
280}
281
282/// Mount the three request verbs. Returns a stateless `Router<()>` ready to merge with
283/// [`crate::MaintenanceModule::read_only_routes`].
284pub fn create_maintenance_request_lifecycle_routes(state: MaintenanceRequestLifecycleState) -> Router {
285    Router::<MaintenanceRequestLifecycleState>::new()
286        .route("/maintenance_requests", post(create_request))
287        .route("/maintenance_requests/:id/transition", post(transition_request))
288        .route("/maintenance_requests/:id", post(update_request))
289        .with_state(state)
290}