Skip to main content

backbone_bucket/presentation/http/
stored_file_handler.rs

1//! StoredFile REST handlers
2//!
3//! Generated by metaphor-schema. Do not edit manually.
4//!
5//! Uses Axum and backbone-core's BackboneCrudHandler for all 12 CRUD endpoints.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15// Backbone framework imports
16use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18// Auth integration (optional)
19#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24// Domain imports
25use crate::domain::entity::*;
26use crate::application::service::{StoredFileService, ServiceError};
27
28// DTO imports
29use crate::presentation::dto::{CreateStoredFileDto, UpdateStoredFileDto, PatchStoredFileDto, StoredFileResponseDto};
30
31use crate::domain::state_machine::{StoredFileState, StoredFileStateMachine, StoredFileTransition};
32
33/// Application error type
34#[derive(Debug, thiserror::Error)]
35pub enum StoredFileError {
36    #[error("Not found: {0}")]
37    NotFound(String),
38    #[error("Validation error: {0}")]
39    Validation(String),
40    #[error("Database error: {0}")]
41    Database(String),
42    #[error("Internal error: {0}")]
43    Internal(String),
44    // Domain-specific errors from hook rules
45    #[error("File path too long (max 1024 characters): {0}")]
46    PathTooLong(String),
47    #[error("Path traversal not allowed: {0}")]
48    PathTraversal(String),
49    #[error("File name too long (max 255 characters): {0}")]
50    FilenameTooLong(String),
51    #[error("Invalid MIME type format: {0}")]
52    InvalidMimeType(String),
53    #[error("Cannot update purged files: {0}")]
54    FilePurged(String),
55    #[error("Only admins can modify quarantined files: {0}")]
56    QuarantineRestricted(String),
57}
58
59impl From<ServiceError> for StoredFileError {
60    fn from(err: ServiceError) -> Self {
61        match err {
62            ServiceError::NotFound => Self::NotFound(err.to_string()),
63            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
64            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
65            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
66            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
67            ServiceError::Violations(_) => Self::Validation(err.to_string()),
68        }
69    }
70}
71
72impl axum::response::IntoResponse for StoredFileError {
73    fn into_response(self) -> axum::response::Response {
74        use axum::http::StatusCode;
75        use axum::Json;
76
77        let (status, code) = match &self {
78            Self::NotFound(_) => (StatusCode::NOT_FOUND, "STOREDFILE_NOT_FOUND"),
79            Self::Validation(_) => (StatusCode::BAD_REQUEST, "STOREDFILE_VALIDATION_ERROR"),
80            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_DATABASE_ERROR"),
81            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "STOREDFILE_INTERNAL_ERROR"),
82            Self::PathTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TOO_LONG"),
83            Self::PathTraversal(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_PATH_TRAVERSAL"),
84            Self::FilenameTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILENAME_TOO_LONG"),
85            Self::InvalidMimeType(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_INVALID_MIME_TYPE"),
86            Self::FilePurged(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_FILE_PURGED"),
87            Self::QuarantineRestricted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "STOREDFILE_QUARANTINE_RESTRICTED"),
88        };
89
90        let body = serde_json::json!({
91            "success": false,
92            "error": code,
93            "message": self.to_string(),
94        });
95
96        (status, Json(body)).into_response()
97    }
98}
99
100/// Domain-specific error codes for StoredFile
101pub mod stored_file_errors {
102    pub const PATH_TOO_LONG: &str = "STOREDFILE_PATH_TOO_LONG";
103    pub const PATH_TRAVERSAL: &str = "STOREDFILE_PATH_TRAVERSAL";
104    pub const FILENAME_TOO_LONG: &str = "STOREDFILE_FILENAME_TOO_LONG";
105    pub const INVALID_MIME_TYPE: &str = "STOREDFILE_INVALID_MIME_TYPE";
106    pub const FILE_PURGED: &str = "STOREDFILE_FILE_PURGED";
107    pub const QUARANTINE_RESTRICTED: &str = "STOREDFILE_QUARANTINE_RESTRICTED";
108}
109
110// =============================================================================
111// Route Configuration
112// =============================================================================
113
114/// Create Axum router with all 16 Backbone endpoints for StoredFile.
115///
116/// # Routes
117///
118/// | Method | Path | Description |
119/// |--------|------|-------------|
120/// | GET | /stored_files | List with pagination |
121/// | POST | /stored_files | Create new |
122/// | GET | /stored_files/:id | Get by ID |
123/// | PUT | /stored_files/:id | Full update |
124/// | PATCH | /stored_files/:id | Partial update |
125/// | DELETE | /stored_files/:id | Soft delete |
126/// | POST | /stored_files/bulk | Bulk create |
127/// | POST | /stored_files/upsert | Upsert |
128/// | GET | /stored_files/trash | List deleted |
129/// | POST | /stored_files/:id/restore | Restore |
130/// | DELETE | /stored_files/empty | Empty trash |
131/// | GET | /stored_files/:id/deleted | Get deleted by ID |
132/// | DELETE | /stored_files/trash/:id | Permanent delete from trash |
133/// | GET | /stored_files/count | Count active entities |
134/// | GET | /stored_files/trash/count | Count deleted entities |
135///
136/// # Example
137///
138/// ```text
139/// let service = Arc::new(StoredFileService::with_repository(repository));
140/// let router = create_stored_file_routes(service);
141/// ```
142pub fn create_stored_file_routes(service: Arc<StoredFileService>) -> Router {
143    BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::routes(
144        service,
145        "/stored_files",
146    )
147}
148
149/// Create Axum router with only the read (GET) endpoints for StoredFile.
150///
151/// Safe for public, unauthenticated exposure (e.g., reference data).
152/// Mutations must be served separately via `create_stored_file_write_routes`,
153/// typically wrapped in an auth middleware layer.
154pub fn create_stored_file_read_routes(service: Arc<StoredFileService>) -> Router {
155    BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::read_routes(
156        service,
157        "/stored_files",
158    )
159}
160
161/// Create Axum router with only the write (mutation) endpoints for StoredFile.
162///
163/// These routes must NOT be publicly exposed. Wrap them with an auth
164/// middleware before nesting into the application router.
165///
166/// # This is unguarded generic CRUD, not a validated write path
167///
168/// These are plain create/update/patch/delete mutations over the entity row —
169/// they bypass all business invariants. If the module exposes a validated write
170/// service (e.g. a command router over its domain engine), serve THAT instead
171/// for any mutation that must respect domain rules.
172pub fn create_stored_file_write_routes(service: Arc<StoredFileService>) -> Router {
173    BackboneCrudHandler::<StoredFileService, StoredFile, CreateStoredFileDto, UpdateStoredFileDto, StoredFileResponseDto>::write_routes(
174        service,
175        "/stored_files",
176    )
177}
178
179/// Create authenticated routes with auth middleware.
180///
181/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
182/// is responsible for extracting and validating tokens, then providing
183/// an `AuthContext` via request extensions.
184#[cfg(feature = "auth")]
185pub fn create_protected_stored_file_routes<A: AuthMiddleware + Send + Sync + 'static>(
186    service: Arc<StoredFileService>,
187    auth: Arc<A>,
188) -> Router {
189    use axum::middleware;
190    use axum::response::IntoResponse;
191
192    let auth_layer = auth.clone();
193    create_stored_file_routes(service)
194        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
195            let auth = auth_layer.clone();
196            async move {
197                let token = req.headers()
198                    .get(axum::http::header::AUTHORIZATION)
199                    .and_then(|h| h.to_str().ok())
200                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
201                    .unwrap_or("");
202                match auth.authenticate(token).await {
203                    Ok(ctx) => {
204                        req.extensions_mut().insert(ctx);
205                        next.run(req).await
206                    }
207                    Err(_) => {
208                        (axum::http::StatusCode::UNAUTHORIZED,
209                         axum::Json(serde_json::json!({
210                             "success": false,
211                             "error": "unauthorized",
212                             "message": "Authentication required"
213                         }))
214                        ).into_response()
215                    }
216                }
217            }
218        }))
219}
220
221// =============================================================================
222// State Transition Handlers
223// =============================================================================
224
225/// Execute complete_upload transition on a StoredFile.
226///
227/// POST /stored_files/:id/transitions/complete_upload
228pub async fn complete_upload_transition(
229    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
230    axum::extract::Path(id): axum::extract::Path<String>,
231    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
232) -> impl axum::response::IntoResponse {
233    use axum::{http::StatusCode, Json};
234
235    // Get current entity
236    let entity = match service.get_by_id(&id).await {
237        Ok(Some(e)) => e,
238        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
239        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
240    };
241
242    // Check permission (if auth enabled)
243    #[cfg(feature = "auth")]
244    {
245        let allowed_roles = StoredFileTransition::CompleteUpload.allowed_roles();
246        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:complete_upload");
247        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
248        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
249        if !has_specific_perm && !has_role {
250            if !has_update_perm {
251                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for complete_upload transition")));
252            }
253            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "complete_upload", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:complete_upload or a role of ["system"]");
254        }
255    }
256
257    // Create state machine from entity's actual status and validate transition
258    let current_state: StoredFileState = match entity.status.to_string().parse() {
259        Ok(s) => s,
260        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
261    };
262    let sm = StoredFileStateMachine::from_state(current_state);
263    if !sm.can_transition(StoredFileTransition::CompleteUpload) {
264        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
265    }
266
267    {
268        use backbone_core::row_rule as r;
269        let row = &serde_json::to_value(&entity).unwrap_or_default();
270        let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
271        // Guard: size_bytes > 0
272        if ["uploading"].contains(&state) && !r::holds(&r::gt(r::field(row, "size_bytes"), r::Val::Num(0.0_f64))) {
273            return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The complete_upload transition's guard does not hold: size_bytes > 0")));
274        }
275    }
276
277    // Apply transition via partial update
278    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
279    fields.insert("status".to_string(), serde_json::Value::String("processing".to_string()));
280
281    match backbone_core::write_guard::as_transition("complete_upload", service.partial_update(&id, fields)).await {
282        Ok(Some(updated)) => {
283            let response: StoredFileResponseDto = updated.into();
284            (StatusCode::OK, Json(ApiResponse::ok(response)))
285        }
286        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
287        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
288    }
289}
290
291/// Execute mark_safe transition on a StoredFile.
292///
293/// POST /stored_files/:id/transitions/mark_safe
294pub async fn mark_safe_transition(
295    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
296    axum::extract::Path(id): axum::extract::Path<String>,
297    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
298) -> impl axum::response::IntoResponse {
299    use axum::{http::StatusCode, Json};
300
301    // Get current entity
302    let entity = match service.get_by_id(&id).await {
303        Ok(Some(e)) => e,
304        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
305        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
306    };
307
308    // Check permission (if auth enabled)
309    #[cfg(feature = "auth")]
310    {
311        let allowed_roles = StoredFileTransition::MarkSafe.allowed_roles();
312        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe");
313        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
314        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
315        if !has_specific_perm && !has_role {
316            if !has_update_perm {
317                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe transition")));
318            }
319            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "mark_safe", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:mark_safe or a role of ["system"]");
320        }
321    }
322
323    // Create state machine from entity's actual status and validate transition
324    let current_state: StoredFileState = match entity.status.to_string().parse() {
325        Ok(s) => s,
326        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
327    };
328    let sm = StoredFileStateMachine::from_state(current_state);
329    if !sm.can_transition(StoredFileTransition::MarkSafe) {
330        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
331    }
332
333    {
334        use backbone_core::row_rule as r;
335        let row = &serde_json::to_value(&entity).unwrap_or_default();
336        let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
337        // Guard: is_scanned == true && (threat_level == null || threat_level == 'safe')
338        if ["processing"].contains(&state) && !r::holds(&r::and(r::eq(r::field(row, "is_scanned"), r::Val::Bool(true)), r::or(r::is_null(r::field(row, "threat_level")), r::eq(r::field(row, "threat_level"), r::text("safe"))))) {
339            return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The mark_safe transition's guard does not hold: is_scanned == true && (threat_level == null || threat_level == 'safe')")));
340        }
341    }
342
343    // Apply transition via partial update
344    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
345    fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
346
347    match backbone_core::write_guard::as_transition("mark_safe", service.partial_update(&id, fields)).await {
348        Ok(Some(updated)) => {
349            let response: StoredFileResponseDto = updated.into();
350            (StatusCode::OK, Json(ApiResponse::ok(response)))
351        }
352        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
353        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
354    }
355}
356
357/// Execute quarantine transition on a StoredFile.
358///
359/// POST /stored_files/:id/transitions/quarantine
360pub async fn quarantine_transition(
361    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
362    axum::extract::Path(id): axum::extract::Path<String>,
363    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
364) -> impl axum::response::IntoResponse {
365    use axum::{http::StatusCode, Json};
366
367    // Get current entity
368    let entity = match service.get_by_id(&id).await {
369        Ok(Some(e)) => e,
370        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
371        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
372    };
373
374    // Check permission (if auth enabled)
375    #[cfg(feature = "auth")]
376    {
377        let allowed_roles = StoredFileTransition::Quarantine.allowed_roles();
378        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:quarantine");
379        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
380        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
381        if !has_specific_perm && !has_role {
382            if !has_update_perm {
383                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for quarantine transition")));
384            }
385            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "quarantine", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:quarantine or a role of ["system"]");
386        }
387    }
388
389    // Create state machine from entity's actual status and validate transition
390    let current_state: StoredFileState = match entity.status.to_string().parse() {
391        Ok(s) => s,
392        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
393    };
394    let sm = StoredFileStateMachine::from_state(current_state);
395    if !sm.can_transition(StoredFileTransition::Quarantine) {
396        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
397    }
398
399    {
400        use backbone_core::row_rule as r;
401        let row = &serde_json::to_value(&entity).unwrap_or_default();
402        let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
403        // Guard: threat_level != null && threat_level != 'safe'
404        if ["processing"].contains(&state) && !r::holds(&r::and(r::is_not_null(r::field(row, "threat_level")), r::ne(r::field(row, "threat_level"), r::text("safe")))) {
405            return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<StoredFileResponseDto>::error("The quarantine transition's guard does not hold: threat_level != null && threat_level != 'safe'")));
406        }
407    }
408
409    // Apply transition via partial update
410    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
411    fields.insert("status".to_string(), serde_json::Value::String("quarantined".to_string()));
412
413    match backbone_core::write_guard::as_transition("quarantine", service.partial_update(&id, fields)).await {
414        Ok(Some(updated)) => {
415            let response: StoredFileResponseDto = updated.into();
416            (StatusCode::OK, Json(ApiResponse::ok(response)))
417        }
418        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
419        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
420    }
421}
422
423/// Execute mark_safe_by_admin transition on a StoredFile.
424///
425/// POST /stored_files/:id/transitions/mark_safe_by_admin
426pub async fn mark_safe_by_admin_transition(
427    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
428    axum::extract::Path(id): axum::extract::Path<String>,
429    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
430) -> impl axum::response::IntoResponse {
431    use axum::{http::StatusCode, Json};
432
433    // Get current entity
434    let entity = match service.get_by_id(&id).await {
435        Ok(Some(e)) => e,
436        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
437        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
438    };
439
440    // Check permission (if auth enabled)
441    #[cfg(feature = "auth")]
442    {
443        let allowed_roles = StoredFileTransition::MarkSafeByAdmin.allowed_roles();
444        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe_by_admin");
445        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
446        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
447        if !has_specific_perm && !has_role {
448            if !has_update_perm {
449                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe_by_admin transition")));
450            }
451            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "mark_safe_by_admin", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:mark_safe_by_admin or a role of ["admin"]");
452        }
453    }
454
455    // Create state machine from entity's actual status and validate transition
456    let current_state: StoredFileState = match entity.status.to_string().parse() {
457        Ok(s) => s,
458        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
459    };
460    let sm = StoredFileStateMachine::from_state(current_state);
461    if !sm.can_transition(StoredFileTransition::MarkSafeByAdmin) {
462        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
463    }
464
465    // Apply transition via partial update
466    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
467    fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
468
469    match backbone_core::write_guard::as_transition("mark_safe_by_admin", service.partial_update(&id, fields)).await {
470        Ok(Some(updated)) => {
471            let response: StoredFileResponseDto = updated.into();
472            (StatusCode::OK, Json(ApiResponse::ok(response)))
473        }
474        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
475        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
476    }
477}
478
479/// Execute soft_delete transition on a StoredFile.
480///
481/// POST /stored_files/:id/transitions/soft_delete
482pub async fn soft_delete_transition(
483    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
484    axum::extract::Path(id): axum::extract::Path<String>,
485    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
486) -> impl axum::response::IntoResponse {
487    use axum::{http::StatusCode, Json};
488
489    // Get current entity
490    let entity = match service.get_by_id(&id).await {
491        Ok(Some(e)) => e,
492        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
493        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
494    };
495
496    // Check permission (if auth enabled)
497    #[cfg(feature = "auth")]
498    {
499        let allowed_roles = StoredFileTransition::SoftDelete.allowed_roles();
500        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:soft_delete");
501        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
502        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
503        if !has_specific_perm && !has_role {
504            if !has_update_perm {
505                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for soft_delete transition")));
506            }
507            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "soft_delete", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:soft_delete or a role of ["admin", "owner"]");
508        }
509    }
510
511    // Create state machine from entity's actual status and validate transition
512    let current_state: StoredFileState = match entity.status.to_string().parse() {
513        Ok(s) => s,
514        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
515    };
516    let sm = StoredFileStateMachine::from_state(current_state);
517    if !sm.can_transition(StoredFileTransition::SoftDelete) {
518        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
519    }
520
521    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
522    // Apply transition via partial update
523    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
524    fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
525
526    match backbone_core::write_guard::as_transition("soft_delete", service.partial_update(&id, fields)).await {
527        Ok(Some(updated)) => {
528            let response: StoredFileResponseDto = updated.into();
529            (StatusCode::OK, Json(ApiResponse::ok(response)))
530        }
531        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
532        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
533    }
534}
535
536/// Execute restore transition on a StoredFile.
537///
538/// POST /stored_files/:id/transitions/restore
539pub async fn restore_transition(
540    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
541    axum::extract::Path(id): axum::extract::Path<String>,
542    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
543) -> impl axum::response::IntoResponse {
544    use axum::{http::StatusCode, Json};
545
546    // Get current entity
547    let entity = match service.get_by_id(&id).await {
548        Ok(Some(e)) => e,
549        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
550        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
551    };
552
553    // Check permission (if auth enabled)
554    #[cfg(feature = "auth")]
555    {
556        let allowed_roles = StoredFileTransition::Restore.allowed_roles();
557        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:restore");
558        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
559        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
560        if !has_specific_perm && !has_role {
561            if !has_update_perm {
562                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for restore transition")));
563            }
564            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "restore", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:restore or a role of ["admin", "owner"]");
565        }
566    }
567
568    // Create state machine from entity's actual status and validate transition
569    let current_state: StoredFileState = match entity.status.to_string().parse() {
570        Ok(s) => s,
571        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
572    };
573    let sm = StoredFileStateMachine::from_state(current_state);
574    if !sm.can_transition(StoredFileTransition::Restore) {
575        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
576    }
577
578    // Guard `metadata.deleted_at != null && $actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
579    // Apply transition via partial update
580    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
581    fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
582
583    match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
584        Ok(Some(updated)) => {
585            let response: StoredFileResponseDto = updated.into();
586            (StatusCode::OK, Json(ApiResponse::ok(response)))
587        }
588        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
589        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
590    }
591}
592
593/// Execute purge transition on a StoredFile.
594///
595/// POST /stored_files/:id/transitions/purge
596pub async fn purge_transition(
597    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
598    axum::extract::Path(id): axum::extract::Path<String>,
599    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
600) -> impl axum::response::IntoResponse {
601    use axum::{http::StatusCode, Json};
602
603    // Get current entity
604    let entity = match service.get_by_id(&id).await {
605        Ok(Some(e)) => e,
606        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
607        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
608    };
609
610    // Check permission (if auth enabled)
611    #[cfg(feature = "auth")]
612    {
613        let allowed_roles = StoredFileTransition::Purge.allowed_roles();
614        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:purge");
615        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
616        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
617        if !has_specific_perm && !has_role {
618            if !has_update_perm {
619                return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for purge transition")));
620            }
621            tracing::warn!(target: "backbone::write_guard", entity = "StoredFile", verb = "purge", code = "transition_by_update_permission", "a transition granted only by stored_file:update, running in shadow; it needs stored_file:transition:purge or a role of ["admin", "system"]");
622        }
623    }
624
625    // Create state machine from entity's actual status and validate transition
626    let current_state: StoredFileState = match entity.status.to_string().parse() {
627        Ok(s) => s,
628        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<StoredFileResponseDto>::error("The stored state is not a state of this machine"))),
629    };
630    let sm = StoredFileStateMachine::from_state(current_state);
631    if !sm.can_transition(StoredFileTransition::Purge) {
632        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
633    }
634
635    // Guard `metadata.deleted_at != null && metadata.deleted_at < now() - 30.days` not compiled (`.days` reads a relation or a duration); the transition is not checked against it.
636    // Apply transition via partial update
637    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
638    fields.insert("status".to_string(), serde_json::Value::String("purged".to_string()));
639
640    match backbone_core::write_guard::as_transition("purge", service.partial_update(&id, fields)).await {
641        Ok(Some(updated)) => {
642            let response: StoredFileResponseDto = updated.into();
643            (StatusCode::OK, Json(ApiResponse::ok(response)))
644        }
645        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
646        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
647    }
648}
649
650/// Create routes for state transitions.
651pub fn create_stored_file_transition_routes(service: Arc<StoredFileService>) -> Router {
652    use axum::routing::post;
653
654    Router::new()
655        .route("/stored_files/:id/transitions/complete_upload", post(complete_upload_transition))
656        .route("/stored_files/:id/transitions/mark_safe", post(mark_safe_transition))
657        .route("/stored_files/:id/transitions/quarantine", post(quarantine_transition))
658        .route("/stored_files/:id/transitions/mark_safe_by_admin", post(mark_safe_by_admin_transition))
659        .route("/stored_files/:id/transitions/soft_delete", post(soft_delete_transition))
660        .route("/stored_files/:id/transitions/restore", post(restore_transition))
661        .route("/stored_files/:id/transitions/purge", post(purge_transition))
662        .with_state(service)
663}
664