Skip to main content

backbone_bucket/presentation/http/
upload_session_handler.rs

1//! UploadSession 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)
19use backbone_auth::middleware::AuthContext;
20#[cfg(feature = "auth")]
21use backbone_auth::AuthMiddleware;
22
23// Domain imports
24use crate::domain::entity::*;
25use crate::application::service::{UploadSessionService, ServiceError};
26
27// DTO imports
28use crate::presentation::dto::{CreateUploadSessionDto, UpdateUploadSessionDto, PatchUploadSessionDto, UploadSessionResponseDto};
29
30use crate::domain::state_machine::{UploadSessionState, UploadSessionStateMachine, UploadSessionTransition};
31
32/// Application error type
33#[derive(Debug, thiserror::Error)]
34pub enum UploadSessionError {
35    #[error("Not found: {0}")]
36    NotFound(String),
37    #[error("Validation error: {0}")]
38    Validation(String),
39    #[error("Database error: {0}")]
40    Database(String),
41    #[error("Internal error: {0}")]
42    Internal(String),
43    // Domain-specific errors from hook rules
44    #[error("Target bucket must exist: {0}")]
45    BucketNotFound(String),
46    #[error("Bucket must accept uploads: {0}")]
47    BucketNotActive(String),
48    #[error("File size must be positive: {0}")]
49    InvalidFileSize(String),
50    #[error("Chunk size must be at least 5MB: {0}")]
51    ChunkTooSmall(String),
52    #[error("Chunk size must not exceed 5GB: {0}")]
53    ChunkTooLarge(String),
54    #[error("Total chunks must match file size and chunk size: {0}")]
55    InvalidChunkCount(String),
56    #[error("Path must be valid and not contain traversal: {0}")]
57    InvalidPath(String),
58    #[error("Expiration must be in future: {0}")]
59    InvalidExpiration(String),
60    #[error("Expiration must be within 7 days: {0}")]
61    ExpirationTooFar(String),
62    #[error("Session has expired: {0}")]
63    SessionExpired(String),
64}
65
66impl From<ServiceError> for UploadSessionError {
67    fn from(err: ServiceError) -> Self {
68        match err {
69            ServiceError::NotFound => Self::NotFound(err.to_string()),
70            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
71            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
72            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
73            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
74            ServiceError::Violations(_) => Self::Validation(err.to_string()),
75        }
76    }
77}
78
79impl axum::response::IntoResponse for UploadSessionError {
80    fn into_response(self) -> axum::response::Response {
81        use axum::http::StatusCode;
82        use axum::Json;
83
84        let (status, code) = match &self {
85            Self::NotFound(_) => (StatusCode::NOT_FOUND, "UPLOADSESSION_NOT_FOUND"),
86            Self::Validation(_) => (StatusCode::BAD_REQUEST, "UPLOADSESSION_VALIDATION_ERROR"),
87            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "UPLOADSESSION_DATABASE_ERROR"),
88            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "UPLOADSESSION_INTERNAL_ERROR"),
89            Self::BucketNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_BUCKET_NOT_FOUND"),
90            Self::BucketNotActive(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_BUCKET_NOT_ACTIVE"),
91            Self::InvalidFileSize(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_FILE_SIZE"),
92            Self::ChunkTooSmall(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_CHUNK_TOO_SMALL"),
93            Self::ChunkTooLarge(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_CHUNK_TOO_LARGE"),
94            Self::InvalidChunkCount(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_CHUNK_COUNT"),
95            Self::InvalidPath(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_PATH"),
96            Self::InvalidExpiration(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_INVALID_EXPIRATION"),
97            Self::ExpirationTooFar(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_EXPIRATION_TOO_FAR"),
98            Self::SessionExpired(_) => (StatusCode::UNPROCESSABLE_ENTITY, "UPLOADSESSION_SESSION_EXPIRED"),
99        };
100
101        let body = serde_json::json!({
102            "success": false,
103            "error": code,
104            "message": self.to_string(),
105        });
106
107        (status, Json(body)).into_response()
108    }
109}
110
111/// Domain-specific error codes for UploadSession
112pub mod upload_session_errors {
113    pub const BUCKET_NOT_FOUND: &str = "UPLOADSESSION_BUCKET_NOT_FOUND";
114    pub const BUCKET_NOT_ACTIVE: &str = "UPLOADSESSION_BUCKET_NOT_ACTIVE";
115    pub const INVALID_FILE_SIZE: &str = "UPLOADSESSION_INVALID_FILE_SIZE";
116    pub const CHUNK_TOO_SMALL: &str = "UPLOADSESSION_CHUNK_TOO_SMALL";
117    pub const CHUNK_TOO_LARGE: &str = "UPLOADSESSION_CHUNK_TOO_LARGE";
118    pub const INVALID_CHUNK_COUNT: &str = "UPLOADSESSION_INVALID_CHUNK_COUNT";
119    pub const INVALID_PATH: &str = "UPLOADSESSION_INVALID_PATH";
120    pub const INVALID_EXPIRATION: &str = "UPLOADSESSION_INVALID_EXPIRATION";
121    pub const EXPIRATION_TOO_FAR: &str = "UPLOADSESSION_EXPIRATION_TOO_FAR";
122    pub const SESSION_EXPIRED: &str = "UPLOADSESSION_SESSION_EXPIRED";
123}
124
125// =============================================================================
126// Route Configuration
127// =============================================================================
128
129/// Create Axum router with all 16 Backbone endpoints for UploadSession.
130///
131/// # Routes
132///
133/// | Method | Path | Description |
134/// |--------|------|-------------|
135/// | GET | /upload_sessions | List with pagination |
136/// | POST | /upload_sessions | Create new |
137/// | GET | /upload_sessions/:id | Get by ID |
138/// | PUT | /upload_sessions/:id | Full update |
139/// | PATCH | /upload_sessions/:id | Partial update |
140/// | DELETE | /upload_sessions/:id | Soft delete |
141/// | POST | /upload_sessions/bulk | Bulk create |
142/// | POST | /upload_sessions/upsert | Upsert |
143/// | GET | /upload_sessions/trash | List deleted |
144/// | POST | /upload_sessions/:id/restore | Restore |
145/// | DELETE | /upload_sessions/empty | Empty trash |
146/// | GET | /upload_sessions/:id/deleted | Get deleted by ID |
147/// | DELETE | /upload_sessions/trash/:id | Permanent delete from trash |
148/// | GET | /upload_sessions/count | Count active entities |
149/// | GET | /upload_sessions/trash/count | Count deleted entities |
150///
151/// # Example
152///
153/// ```text
154/// let service = Arc::new(UploadSessionService::with_repository(repository));
155/// let router = create_upload_session_routes(service);
156/// ```
157pub fn create_upload_session_routes(service: Arc<UploadSessionService>) -> Router {
158    BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::routes(
159        service,
160        "/upload_sessions",
161    )
162}
163
164/// Create Axum router with only the read (GET) endpoints for UploadSession.
165///
166/// Safe for public, unauthenticated exposure (e.g., reference data).
167/// Mutations must be served separately via `create_upload_session_write_routes`,
168/// typically wrapped in an auth middleware layer.
169pub fn create_upload_session_read_routes(service: Arc<UploadSessionService>) -> Router {
170    BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::read_routes(
171        service,
172        "/upload_sessions",
173    )
174}
175
176/// Create Axum router with only the write (mutation) endpoints for UploadSession.
177///
178/// These routes must NOT be publicly exposed. Wrap them with an auth
179/// middleware before nesting into the application router.
180///
181/// # This is unguarded generic CRUD, not a validated write path
182///
183/// These are plain create/update/patch/delete mutations over the entity row —
184/// they bypass all business invariants. If the module exposes a validated write
185/// service (e.g. a command router over its domain engine), serve THAT instead
186/// for any mutation that must respect domain rules.
187pub fn create_upload_session_write_routes(service: Arc<UploadSessionService>) -> Router {
188    BackboneCrudHandler::<UploadSessionService, UploadSession, CreateUploadSessionDto, UpdateUploadSessionDto, UploadSessionResponseDto>::write_routes(
189        service,
190        "/upload_sessions",
191    )
192}
193
194/// Create authenticated routes with auth middleware.
195///
196/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
197/// is responsible for extracting and validating tokens, then providing
198/// an `AuthContext` via request extensions.
199#[cfg(feature = "auth")]
200pub fn create_protected_upload_session_routes<A: AuthMiddleware + Send + Sync + 'static>(
201    service: Arc<UploadSessionService>,
202    auth: Arc<A>,
203) -> Router {
204    use axum::middleware;
205    use axum::response::IntoResponse;
206
207    let auth_layer = auth.clone();
208    create_upload_session_routes(service)
209        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
210            let auth = auth_layer.clone();
211            async move {
212                let token = req.headers()
213                    .get(axum::http::header::AUTHORIZATION)
214                    .and_then(|h| h.to_str().ok())
215                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
216                    .unwrap_or("");
217                match auth.authenticate(token).await {
218                    Ok(ctx) => {
219                        req.extensions_mut().insert(ctx);
220                        next.run(req).await
221                    }
222                    Err(_) => {
223                        (axum::http::StatusCode::UNAUTHORIZED,
224                         axum::Json(serde_json::json!({
225                             "success": false,
226                             "error": "unauthorized",
227                             "message": "Authentication required"
228                         }))
229                        ).into_response()
230                    }
231                }
232            }
233        }))
234}
235
236// =============================================================================
237// State Transition Handlers
238// =============================================================================
239
240/// Execute start_upload transition on a UploadSession.
241///
242/// POST /upload_sessions/:id/transitions/start_upload
243pub async fn start_upload_transition(
244    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
245    axum::extract::Path(id): axum::extract::Path<String>,
246    auth: Option<axum::Extension<AuthContext>>,
247) -> impl axum::response::IntoResponse {
248    use axum::{http::StatusCode, Json};
249
250    // Get current entity
251    let entity = match service.get_by_id(&id).await {
252        Ok(Some(e)) => e,
253        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
254        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
255    };
256
257    // Check permission
258    let Some(axum::Extension(auth)) = auth else {
259        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
260    };
261    {
262        let allowed_roles = UploadSessionTransition::StartUpload.allowed_roles();
263        let granted = |required: &str| auth.permissions.iter().any(|p| {
264            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
265            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
266                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
267        });
268        let has_specific_perm = granted("upload_session:transition:start_upload");
269        let has_update_perm = granted("upload_session:update");
270        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
271        if !has_specific_perm && !has_role {
272            if !has_update_perm {
273                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for start_upload transition")));
274            }
275            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "start_upload", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:start_upload or one of the roles system, user");
276        }
277    }
278
279    // Create state machine from entity's actual status and validate transition
280    let current_state: UploadSessionState = match entity.status.to_string().parse() {
281        Ok(s) => s,
282        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
283    };
284    let sm = UploadSessionStateMachine::from_state(current_state);
285    if !sm.can_transition(UploadSessionTransition::StartUpload) {
286        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
287    }
288
289    // Guard `now() - metadata.created_at <= INTERVAL '1 hour'` not compiled (unexpected `'1 hour'` after a complete expression); the transition is not checked against it.
290    // Apply transition via partial update
291    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
292    fields.insert("status".to_string(), serde_json::Value::String("uploading".to_string()));
293
294    match backbone_core::write_guard::as_transition("start_upload", service.partial_update(&id, fields)).await {
295        Ok(Some(updated)) => {
296            let response: UploadSessionResponseDto = updated.into();
297            (StatusCode::OK, Json(ApiResponse::ok(response)))
298        }
299        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
300        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
301    }
302}
303
304/// Execute add_part transition on a UploadSession.
305///
306/// POST /upload_sessions/:id/transitions/add_part
307pub async fn add_part_transition(
308    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
309    axum::extract::Path(id): axum::extract::Path<String>,
310    auth: Option<axum::Extension<AuthContext>>,
311) -> impl axum::response::IntoResponse {
312    use axum::{http::StatusCode, Json};
313
314    // Get current entity
315    let entity = match service.get_by_id(&id).await {
316        Ok(Some(e)) => e,
317        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
318        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
319    };
320
321    // Check permission
322    let Some(axum::Extension(auth)) = auth else {
323        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
324    };
325    {
326        let allowed_roles = UploadSessionTransition::AddPart.allowed_roles();
327        let granted = |required: &str| auth.permissions.iter().any(|p| {
328            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
329            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
330                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
331        });
332        let has_specific_perm = granted("upload_session:transition:add_part");
333        let has_update_perm = granted("upload_session:update");
334        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
335        if !has_specific_perm && !has_role {
336            if !has_update_perm {
337                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for add_part transition")));
338            }
339            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "add_part", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:add_part or one of the roles system, user");
340        }
341    }
342
343    // Create state machine from entity's actual status and validate transition
344    let current_state: UploadSessionState = match entity.status.to_string().parse() {
345        Ok(s) => s,
346        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
347    };
348    let sm = UploadSessionStateMachine::from_state(current_state);
349    if !sm.can_transition(UploadSessionTransition::AddPart) {
350        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
351    }
352
353    // Guard `$context.part_number >= 1 && $context.part_number <= total_chunks && !completed_parts.contains($context.part_number)` not compiled (reads `$context.part_number`, which a row rule cannot); the transition is not checked against it.
354    // Apply transition via partial update
355    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
356    fields.insert("status".to_string(), serde_json::Value::String("uploading".to_string()));
357
358    match backbone_core::write_guard::as_transition("add_part", service.partial_update(&id, fields)).await {
359        Ok(Some(updated)) => {
360            let response: UploadSessionResponseDto = updated.into();
361            (StatusCode::OK, Json(ApiResponse::ok(response)))
362        }
363        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
364        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
365    }
366}
367
368/// Execute complete transition on a UploadSession.
369///
370/// POST /upload_sessions/:id/transitions/complete
371pub async fn complete_transition(
372    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
373    axum::extract::Path(id): axum::extract::Path<String>,
374    auth: Option<axum::Extension<AuthContext>>,
375) -> impl axum::response::IntoResponse {
376    use axum::{http::StatusCode, Json};
377
378    // Get current entity
379    let entity = match service.get_by_id(&id).await {
380        Ok(Some(e)) => e,
381        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
382        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
383    };
384
385    // Check permission
386    let Some(axum::Extension(auth)) = auth else {
387        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
388    };
389    {
390        let allowed_roles = UploadSessionTransition::Complete.allowed_roles();
391        let granted = |required: &str| auth.permissions.iter().any(|p| {
392            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
393            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
394                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
395        });
396        let has_specific_perm = granted("upload_session:transition:complete");
397        let has_update_perm = granted("upload_session:update");
398        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
399        if !has_specific_perm && !has_role {
400            if !has_update_perm {
401                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for complete transition")));
402            }
403            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "complete", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:complete or one of the roles system, user");
404        }
405    }
406
407    // Create state machine from entity's actual status and validate transition
408    let current_state: UploadSessionState = match entity.status.to_string().parse() {
409        Ok(s) => s,
410        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
411    };
412    let sm = UploadSessionStateMachine::from_state(current_state);
413    if !sm.can_transition(UploadSessionTransition::Complete) {
414        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
415    }
416
417    {
418        use backbone_core::row_rule as r;
419        let row = &serde_json::to_value(&entity).unwrap_or_default();
420        let state = row.get("status").and_then(|v| v.as_str()).unwrap_or_default();
421        // Guard: uploaded_chunks == total_chunks
422        if ["uploading"].contains(&state) && !r::holds(&r::eq(r::field(row, "uploaded_chunks"), r::field(row, "total_chunks"))) {
423            return (StatusCode::UNPROCESSABLE_ENTITY, Json(ApiResponse::<UploadSessionResponseDto>::error("The complete transition's guard does not hold: uploaded_chunks == total_chunks")));
424        }
425    }
426
427    // Apply transition via partial update
428    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
429    fields.insert("status".to_string(), serde_json::Value::String("completing".to_string()));
430
431    match backbone_core::write_guard::as_transition("complete", service.partial_update(&id, fields)).await {
432        Ok(Some(updated)) => {
433            let response: UploadSessionResponseDto = updated.into();
434            (StatusCode::OK, Json(ApiResponse::ok(response)))
435        }
436        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
437        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
438    }
439}
440
441/// Execute finish transition on a UploadSession.
442///
443/// POST /upload_sessions/:id/transitions/finish
444pub async fn finish_transition(
445    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
446    axum::extract::Path(id): axum::extract::Path<String>,
447    auth: Option<axum::Extension<AuthContext>>,
448) -> impl axum::response::IntoResponse {
449    use axum::{http::StatusCode, Json};
450
451    // Get current entity
452    let entity = match service.get_by_id(&id).await {
453        Ok(Some(e)) => e,
454        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
455        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
456    };
457
458    // Check permission
459    let Some(axum::Extension(auth)) = auth else {
460        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
461    };
462    {
463        let allowed_roles = UploadSessionTransition::Finish.allowed_roles();
464        let granted = |required: &str| auth.permissions.iter().any(|p| {
465            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
466            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
467                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
468        });
469        let has_specific_perm = granted("upload_session:transition:finish");
470        let has_update_perm = granted("upload_session:update");
471        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
472        if !has_specific_perm && !has_role {
473            if !has_update_perm {
474                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for finish transition")));
475            }
476            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "finish", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:finish or one of the roles system");
477        }
478    }
479
480    // Create state machine from entity's actual status and validate transition
481    let current_state: UploadSessionState = match entity.status.to_string().parse() {
482        Ok(s) => s,
483        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
484    };
485    let sm = UploadSessionStateMachine::from_state(current_state);
486    if !sm.can_transition(UploadSessionTransition::Finish) {
487        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
488    }
489
490    // Apply transition via partial update
491    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
492    fields.insert("status".to_string(), serde_json::Value::String("completed".to_string()));
493
494    match backbone_core::write_guard::as_transition("finish", service.partial_update(&id, fields)).await {
495        Ok(Some(updated)) => {
496            let response: UploadSessionResponseDto = updated.into();
497            (StatusCode::OK, Json(ApiResponse::ok(response)))
498        }
499        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
500        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
501    }
502}
503
504/// Execute fail transition on a UploadSession.
505///
506/// POST /upload_sessions/:id/transitions/fail
507pub async fn fail_transition(
508    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
509    axum::extract::Path(id): axum::extract::Path<String>,
510    auth: Option<axum::Extension<AuthContext>>,
511) -> impl axum::response::IntoResponse {
512    use axum::{http::StatusCode, Json};
513
514    // Get current entity
515    let entity = match service.get_by_id(&id).await {
516        Ok(Some(e)) => e,
517        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
518        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
519    };
520
521    // Check permission
522    let Some(axum::Extension(auth)) = auth else {
523        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
524    };
525    {
526        let allowed_roles = UploadSessionTransition::Fail.allowed_roles();
527        let granted = |required: &str| auth.permissions.iter().any(|p| {
528            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
529            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
530                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
531        });
532        let has_specific_perm = granted("upload_session:transition:fail");
533        let has_update_perm = granted("upload_session:update");
534        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
535        if !has_specific_perm && !has_role {
536            if !has_update_perm {
537                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for fail transition")));
538            }
539            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "fail", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:fail or one of the roles system");
540        }
541    }
542
543    // Create state machine from entity's actual status and validate transition
544    let current_state: UploadSessionState = match entity.status.to_string().parse() {
545        Ok(s) => s,
546        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
547    };
548    let sm = UploadSessionStateMachine::from_state(current_state);
549    if !sm.can_transition(UploadSessionTransition::Fail) {
550        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
551    }
552
553    // Apply transition via partial update
554    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
555    fields.insert("status".to_string(), serde_json::Value::String("failed".to_string()));
556
557    match backbone_core::write_guard::as_transition("fail", service.partial_update(&id, fields)).await {
558        Ok(Some(updated)) => {
559            let response: UploadSessionResponseDto = updated.into();
560            (StatusCode::OK, Json(ApiResponse::ok(response)))
561        }
562        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
563        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
564    }
565}
566
567/// Execute abort transition on a UploadSession.
568///
569/// POST /upload_sessions/:id/transitions/abort
570pub async fn abort_transition(
571    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
572    axum::extract::Path(id): axum::extract::Path<String>,
573    auth: Option<axum::Extension<AuthContext>>,
574) -> impl axum::response::IntoResponse {
575    use axum::{http::StatusCode, Json};
576
577    // Get current entity
578    let entity = match service.get_by_id(&id).await {
579        Ok(Some(e)) => e,
580        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
581        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
582    };
583
584    // Check permission
585    let Some(axum::Extension(auth)) = auth else {
586        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
587    };
588    {
589        let allowed_roles = UploadSessionTransition::Abort.allowed_roles();
590        let granted = |required: &str| auth.permissions.iter().any(|p| {
591            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
592            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
593                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
594        });
595        let has_specific_perm = granted("upload_session:transition:abort");
596        let has_update_perm = granted("upload_session:update");
597        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
598        if !has_specific_perm && !has_role {
599            if !has_update_perm {
600                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for abort transition")));
601            }
602            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "abort", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:abort or one of the roles user, admin");
603        }
604    }
605
606    // Create state machine from entity's actual status and validate transition
607    let current_state: UploadSessionState = match entity.status.to_string().parse() {
608        Ok(s) => s,
609        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
610    };
611    let sm = UploadSessionStateMachine::from_state(current_state);
612    if !sm.can_transition(UploadSessionTransition::Abort) {
613        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
614    }
615
616    // Guard `$actor.id == user_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.
617    // Apply transition via partial update
618    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
619    fields.insert("status".to_string(), serde_json::Value::String("failed".to_string()));
620
621    match backbone_core::write_guard::as_transition("abort", service.partial_update(&id, fields)).await {
622        Ok(Some(updated)) => {
623            let response: UploadSessionResponseDto = updated.into();
624            (StatusCode::OK, Json(ApiResponse::ok(response)))
625        }
626        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
627        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
628    }
629}
630
631/// Execute expire transition on a UploadSession.
632///
633/// POST /upload_sessions/:id/transitions/expire
634pub async fn expire_transition(
635    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
636    axum::extract::Path(id): axum::extract::Path<String>,
637    auth: Option<axum::Extension<AuthContext>>,
638) -> impl axum::response::IntoResponse {
639    use axum::{http::StatusCode, Json};
640
641    // Get current entity
642    let entity = match service.get_by_id(&id).await {
643        Ok(Some(e)) => e,
644        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
645        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
646    };
647
648    // Check permission
649    let Some(axum::Extension(auth)) = auth else {
650        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
651    };
652    {
653        let allowed_roles = UploadSessionTransition::Expire.allowed_roles();
654        let granted = |required: &str| auth.permissions.iter().any(|p| {
655            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
656            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
657                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
658        });
659        let has_specific_perm = granted("upload_session:transition:expire");
660        let has_update_perm = granted("upload_session:update");
661        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
662        if !has_specific_perm && !has_role {
663            if !has_update_perm {
664                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for expire transition")));
665            }
666            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "expire", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:expire or one of the roles system");
667        }
668    }
669
670    // Create state machine from entity's actual status and validate transition
671    let current_state: UploadSessionState = match entity.status.to_string().parse() {
672        Ok(s) => s,
673        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
674    };
675    let sm = UploadSessionStateMachine::from_state(current_state);
676    if !sm.can_transition(UploadSessionTransition::Expire) {
677        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
678    }
679
680    // Guard `expires_at < now()` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
681    // Apply transition via partial update
682    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
683    fields.insert("status".to_string(), serde_json::Value::String("expired".to_string()));
684
685    match backbone_core::write_guard::as_transition("expire", service.partial_update(&id, fields)).await {
686        Ok(Some(updated)) => {
687            let response: UploadSessionResponseDto = updated.into();
688            (StatusCode::OK, Json(ApiResponse::ok(response)))
689        }
690        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
691        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
692    }
693}
694
695/// Execute retry transition on a UploadSession.
696///
697/// POST /upload_sessions/:id/transitions/retry
698pub async fn retry_transition(
699    axum::extract::State(service): axum::extract::State<Arc<UploadSessionService>>,
700    axum::extract::Path(id): axum::extract::Path<String>,
701    auth: Option<axum::Extension<AuthContext>>,
702) -> impl axum::response::IntoResponse {
703    use axum::{http::StatusCode, Json};
704
705    // Get current entity
706    let entity = match service.get_by_id(&id).await {
707        Ok(Some(e)) => e,
708        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
709        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
710    };
711
712    // Check permission
713    let Some(axum::Extension(auth)) = auth else {
714        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UploadSessionResponseDto>::error("A transition needs an authenticated caller")));
715    };
716    {
717        let allowed_roles = UploadSessionTransition::Retry.allowed_roles();
718        let granted = |required: &str| auth.permissions.iter().any(|p| {
719            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
720            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
721                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
722        });
723        let has_specific_perm = granted("upload_session:transition:retry");
724        let has_update_perm = granted("upload_session:update");
725        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
726        if !has_specific_perm && !has_role {
727            if !has_update_perm {
728                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UploadSessionResponseDto>::error("Insufficient permissions for retry transition")));
729            }
730            tracing::warn!(target: "backbone::write_guard", entity = "UploadSession", verb = "retry", code = "transition_by_update_permission", "a transition granted only by upload_session:update, running in shadow; it needs upload_session:transition:retry or one of the roles user");
731        }
732    }
733
734    // Create state machine from entity's actual status and validate transition
735    let current_state: UploadSessionState = match entity.status.to_string().parse() {
736        Ok(s) => s,
737        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UploadSessionResponseDto>::error("The stored state is not a state of this machine"))),
738    };
739    let sm = UploadSessionStateMachine::from_state(current_state);
740    if !sm.can_transition(UploadSessionTransition::Retry) {
741        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UploadSessionResponseDto>::error("Transition not allowed from current state")));
742    }
743
744    // Guard `$actor.id == user_id` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
745    // Apply transition via partial update
746    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
747    fields.insert("status".to_string(), serde_json::Value::String("initiated".to_string()));
748
749    match backbone_core::write_guard::as_transition("retry", service.partial_update(&id, fields)).await {
750        Ok(Some(updated)) => {
751            let response: UploadSessionResponseDto = updated.into();
752            (StatusCode::OK, Json(ApiResponse::ok(response)))
753        }
754        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UploadSessionResponseDto>::not_found("UploadSession", &id))),
755        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UploadSessionResponseDto>::error(e.to_string()))),
756    }
757}
758
759/// Create routes for state transitions.
760pub fn create_upload_session_transition_routes(service: Arc<UploadSessionService>) -> Router {
761    use axum::routing::post;
762
763    Router::new()
764        .route("/upload_sessions/:id/transitions/start_upload", post(start_upload_transition))
765        .route("/upload_sessions/:id/transitions/add_part", post(add_part_transition))
766        .route("/upload_sessions/:id/transitions/complete", post(complete_transition))
767        .route("/upload_sessions/:id/transitions/finish", post(finish_transition))
768        .route("/upload_sessions/:id/transitions/fail", post(fail_transition))
769        .route("/upload_sessions/:id/transitions/abort", post(abort_transition))
770        .route("/upload_sessions/:id/transitions/expire", post(expire_transition))
771        .route("/upload_sessions/:id/transitions/retry", post(retry_transition))
772        .with_state(service)
773}
774