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_update_perm && !has_role {
250            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for complete_upload transition")));
251        }
252    }
253
254    // Create state machine from entity's actual status and validate transition
255    let current_state: StoredFileState = entity.status.to_string().parse()
256        .unwrap_or(StoredFileState::default());
257    let sm = StoredFileStateMachine::from_state(current_state);
258    if !sm.can_transition(StoredFileTransition::CompleteUpload) {
259        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
260    }
261
262    // Apply transition via partial update
263    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
264    fields.insert("status".to_string(), serde_json::Value::String("Processing".to_string()));
265
266    match service.partial_update(&id, fields).await {
267        Ok(Some(updated)) => {
268            let response: StoredFileResponseDto = updated.into();
269            (StatusCode::OK, Json(ApiResponse::ok(response)))
270        }
271        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
272        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
273    }
274}
275
276/// Execute mark_safe transition on a StoredFile.
277///
278/// POST /stored_files/:id/transitions/mark_safe
279pub async fn mark_safe_transition(
280    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
281    axum::extract::Path(id): axum::extract::Path<String>,
282    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
283) -> impl axum::response::IntoResponse {
284    use axum::{http::StatusCode, Json};
285
286    // Get current entity
287    let entity = match service.get_by_id(&id).await {
288        Ok(Some(e)) => e,
289        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
290        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
291    };
292
293    // Check permission (if auth enabled)
294    #[cfg(feature = "auth")]
295    {
296        let allowed_roles = StoredFileTransition::MarkSafe.allowed_roles();
297        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe");
298        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
299        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
300        if !has_specific_perm && !has_update_perm && !has_role {
301            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe transition")));
302        }
303    }
304
305    // Create state machine from entity's actual status and validate transition
306    let current_state: StoredFileState = entity.status.to_string().parse()
307        .unwrap_or(StoredFileState::default());
308    let sm = StoredFileStateMachine::from_state(current_state);
309    if !sm.can_transition(StoredFileTransition::MarkSafe) {
310        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
311    }
312
313    // Apply transition via partial update
314    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
315    fields.insert("status".to_string(), serde_json::Value::String("Active".to_string()));
316
317    match service.partial_update(&id, fields).await {
318        Ok(Some(updated)) => {
319            let response: StoredFileResponseDto = updated.into();
320            (StatusCode::OK, Json(ApiResponse::ok(response)))
321        }
322        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
323        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
324    }
325}
326
327/// Execute quarantine transition on a StoredFile.
328///
329/// POST /stored_files/:id/transitions/quarantine
330pub async fn quarantine_transition(
331    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
332    axum::extract::Path(id): axum::extract::Path<String>,
333    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
334) -> impl axum::response::IntoResponse {
335    use axum::{http::StatusCode, Json};
336
337    // Get current entity
338    let entity = match service.get_by_id(&id).await {
339        Ok(Some(e)) => e,
340        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
341        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
342    };
343
344    // Check permission (if auth enabled)
345    #[cfg(feature = "auth")]
346    {
347        let allowed_roles = StoredFileTransition::Quarantine.allowed_roles();
348        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:quarantine");
349        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
350        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
351        if !has_specific_perm && !has_update_perm && !has_role {
352            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for quarantine transition")));
353        }
354    }
355
356    // Create state machine from entity's actual status and validate transition
357    let current_state: StoredFileState = entity.status.to_string().parse()
358        .unwrap_or(StoredFileState::default());
359    let sm = StoredFileStateMachine::from_state(current_state);
360    if !sm.can_transition(StoredFileTransition::Quarantine) {
361        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
362    }
363
364    // Apply transition via partial update
365    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
366    fields.insert("status".to_string(), serde_json::Value::String("Quarantined".to_string()));
367
368    match service.partial_update(&id, fields).await {
369        Ok(Some(updated)) => {
370            let response: StoredFileResponseDto = updated.into();
371            (StatusCode::OK, Json(ApiResponse::ok(response)))
372        }
373        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
374        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
375    }
376}
377
378/// Execute mark_safe_by_admin transition on a StoredFile.
379///
380/// POST /stored_files/:id/transitions/mark_safe_by_admin
381pub async fn mark_safe_by_admin_transition(
382    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
383    axum::extract::Path(id): axum::extract::Path<String>,
384    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
385) -> impl axum::response::IntoResponse {
386    use axum::{http::StatusCode, Json};
387
388    // Get current entity
389    let entity = match service.get_by_id(&id).await {
390        Ok(Some(e)) => e,
391        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
392        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
393    };
394
395    // Check permission (if auth enabled)
396    #[cfg(feature = "auth")]
397    {
398        let allowed_roles = StoredFileTransition::MarkSafeByAdmin.allowed_roles();
399        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:mark_safe_by_admin");
400        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
401        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
402        if !has_specific_perm && !has_update_perm && !has_role {
403            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for mark_safe_by_admin transition")));
404        }
405    }
406
407    // Create state machine from entity's actual status and validate transition
408    let current_state: StoredFileState = entity.status.to_string().parse()
409        .unwrap_or(StoredFileState::default());
410    let sm = StoredFileStateMachine::from_state(current_state);
411    if !sm.can_transition(StoredFileTransition::MarkSafeByAdmin) {
412        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
413    }
414
415    // Apply transition via partial update
416    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
417    fields.insert("status".to_string(), serde_json::Value::String("Active".to_string()));
418
419    match service.partial_update(&id, fields).await {
420        Ok(Some(updated)) => {
421            let response: StoredFileResponseDto = updated.into();
422            (StatusCode::OK, Json(ApiResponse::ok(response)))
423        }
424        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
425        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
426    }
427}
428
429/// Execute soft_delete transition on a StoredFile.
430///
431/// POST /stored_files/:id/transitions/soft_delete
432pub async fn soft_delete_transition(
433    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
434    axum::extract::Path(id): axum::extract::Path<String>,
435    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
436) -> impl axum::response::IntoResponse {
437    use axum::{http::StatusCode, Json};
438
439    // Get current entity
440    let entity = match service.get_by_id(&id).await {
441        Ok(Some(e)) => e,
442        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
443        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
444    };
445
446    // Check permission (if auth enabled)
447    #[cfg(feature = "auth")]
448    {
449        let allowed_roles = StoredFileTransition::SoftDelete.allowed_roles();
450        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:soft_delete");
451        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
452        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
453        if !has_specific_perm && !has_update_perm && !has_role {
454            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for soft_delete transition")));
455        }
456    }
457
458    // Create state machine from entity's actual status and validate transition
459    let current_state: StoredFileState = entity.status.to_string().parse()
460        .unwrap_or(StoredFileState::default());
461    let sm = StoredFileStateMachine::from_state(current_state);
462    if !sm.can_transition(StoredFileTransition::SoftDelete) {
463        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
464    }
465
466    // Apply transition via partial update
467    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
468    fields.insert("status".to_string(), serde_json::Value::String("Deleted".to_string()));
469
470    match service.partial_update(&id, fields).await {
471        Ok(Some(updated)) => {
472            let response: StoredFileResponseDto = updated.into();
473            (StatusCode::OK, Json(ApiResponse::ok(response)))
474        }
475        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
476        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
477    }
478}
479
480/// Execute restore transition on a StoredFile.
481///
482/// POST /stored_files/:id/transitions/restore
483pub async fn restore_transition(
484    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
485    axum::extract::Path(id): axum::extract::Path<String>,
486    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
487) -> impl axum::response::IntoResponse {
488    use axum::{http::StatusCode, Json};
489
490    // Get current entity
491    let entity = match service.get_by_id(&id).await {
492        Ok(Some(e)) => e,
493        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
494        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
495    };
496
497    // Check permission (if auth enabled)
498    #[cfg(feature = "auth")]
499    {
500        let allowed_roles = StoredFileTransition::Restore.allowed_roles();
501        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:restore");
502        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
503        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
504        if !has_specific_perm && !has_update_perm && !has_role {
505            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for restore transition")));
506        }
507    }
508
509    // Create state machine from entity's actual status and validate transition
510    let current_state: StoredFileState = entity.status.to_string().parse()
511        .unwrap_or(StoredFileState::default());
512    let sm = StoredFileStateMachine::from_state(current_state);
513    if !sm.can_transition(StoredFileTransition::Restore) {
514        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
515    }
516
517    // Apply transition via partial update
518    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
519    fields.insert("status".to_string(), serde_json::Value::String("Active".to_string()));
520
521    match service.partial_update(&id, fields).await {
522        Ok(Some(updated)) => {
523            let response: StoredFileResponseDto = updated.into();
524            (StatusCode::OK, Json(ApiResponse::ok(response)))
525        }
526        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
527        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
528    }
529}
530
531/// Execute purge transition on a StoredFile.
532///
533/// POST /stored_files/:id/transitions/purge
534pub async fn purge_transition(
535    axum::extract::State(service): axum::extract::State<Arc<StoredFileService>>,
536    axum::extract::Path(id): axum::extract::Path<String>,
537    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
538) -> impl axum::response::IntoResponse {
539    use axum::{http::StatusCode, Json};
540
541    // Get current entity
542    let entity = match service.get_by_id(&id).await {
543        Ok(Some(e)) => e,
544        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
545        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
546    };
547
548    // Check permission (if auth enabled)
549    #[cfg(feature = "auth")]
550    {
551        let allowed_roles = StoredFileTransition::Purge.allowed_roles();
552        let has_specific_perm = auth.permissions.iter().any(|p| p == "stored_file:transition:purge");
553        let has_update_perm = auth.permissions.iter().any(|p| p == "stored_file:update");
554        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
555        if !has_specific_perm && !has_update_perm && !has_role {
556            return (StatusCode::FORBIDDEN, Json(ApiResponse::<StoredFileResponseDto>::error("Insufficient permissions for purge transition")));
557        }
558    }
559
560    // Create state machine from entity's actual status and validate transition
561    let current_state: StoredFileState = entity.status.to_string().parse()
562        .unwrap_or(StoredFileState::default());
563    let sm = StoredFileStateMachine::from_state(current_state);
564    if !sm.can_transition(StoredFileTransition::Purge) {
565        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<StoredFileResponseDto>::error("Transition not allowed from current state")));
566    }
567
568    // Apply transition via partial update
569    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
570    fields.insert("status".to_string(), serde_json::Value::String("Purged".to_string()));
571
572    match service.partial_update(&id, fields).await {
573        Ok(Some(updated)) => {
574            let response: StoredFileResponseDto = updated.into();
575            (StatusCode::OK, Json(ApiResponse::ok(response)))
576        }
577        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<StoredFileResponseDto>::not_found("StoredFile", &id))),
578        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<StoredFileResponseDto>::error(e.to_string()))),
579    }
580}
581
582/// Create routes for state transitions.
583pub fn create_stored_file_transition_routes(service: Arc<StoredFileService>) -> Router {
584    use axum::routing::post;
585
586    Router::new()
587        .route("/stored_files/:id/transitions/complete_upload", post(complete_upload_transition))
588        .route("/stored_files/:id/transitions/mark_safe", post(mark_safe_transition))
589        .route("/stored_files/:id/transitions/quarantine", post(quarantine_transition))
590        .route("/stored_files/:id/transitions/mark_safe_by_admin", post(mark_safe_by_admin_transition))
591        .route("/stored_files/:id/transitions/soft_delete", post(soft_delete_transition))
592        .route("/stored_files/:id/transitions/restore", post(restore_transition))
593        .route("/stored_files/:id/transitions/purge", post(purge_transition))
594        .with_state(service)
595}