Skip to main content

backbone_bucket/presentation/http/
conversion_job_handler.rs

1//! ConversionJob 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::{ConversionJobService, ServiceError};
27
28// DTO imports
29use crate::presentation::dto::{CreateConversionJobDto, UpdateConversionJobDto, PatchConversionJobDto, ConversionJobResponseDto};
30
31use crate::domain::state_machine::{ConversionJobState, ConversionJobStateMachine, ConversionJobTransition};
32
33/// Application error type
34#[derive(Debug, thiserror::Error)]
35pub enum ConversionJobError {
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("Source file must exist: {0}")]
46    SourceFileNotFound(String),
47    #[error("Source file must be accessible: {0}")]
48    FileNotAccessible(String),
49    #[error("Target format must be supported: {0}")]
50    ConversionNotSupported(String),
51    #[error("Insufficient storage quota for conversion result: {0}")]
52    InsufficientQuota(String),
53    #[error("A pending conversion with same params already exists: {0}")]
54    DuplicateConversion(String),
55    #[error("Progress cannot decrease: {0}")]
56    InvalidProgress(String),
57}
58
59impl From<ServiceError> for ConversionJobError {
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 ConversionJobError {
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, "CONVERSIONJOB_NOT_FOUND"),
79            Self::Validation(_) => (StatusCode::BAD_REQUEST, "CONVERSIONJOB_VALIDATION_ERROR"),
80            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "CONVERSIONJOB_DATABASE_ERROR"),
81            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "CONVERSIONJOB_INTERNAL_ERROR"),
82            Self::SourceFileNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_SOURCE_FILE_NOT_FOUND"),
83            Self::FileNotAccessible(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_FILE_NOT_ACCESSIBLE"),
84            Self::ConversionNotSupported(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_CONVERSION_NOT_SUPPORTED"),
85            Self::InsufficientQuota(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_INSUFFICIENT_QUOTA"),
86            Self::DuplicateConversion(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_DUPLICATE_CONVERSION"),
87            Self::InvalidProgress(_) => (StatusCode::UNPROCESSABLE_ENTITY, "CONVERSIONJOB_INVALID_PROGRESS"),
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 ConversionJob
101pub mod conversion_job_errors {
102    pub const SOURCE_FILE_NOT_FOUND: &str = "CONVERSIONJOB_SOURCE_FILE_NOT_FOUND";
103    pub const FILE_NOT_ACCESSIBLE: &str = "CONVERSIONJOB_FILE_NOT_ACCESSIBLE";
104    pub const CONVERSION_NOT_SUPPORTED: &str = "CONVERSIONJOB_CONVERSION_NOT_SUPPORTED";
105    pub const INSUFFICIENT_QUOTA: &str = "CONVERSIONJOB_INSUFFICIENT_QUOTA";
106    pub const DUPLICATE_CONVERSION: &str = "CONVERSIONJOB_DUPLICATE_CONVERSION";
107    pub const INVALID_PROGRESS: &str = "CONVERSIONJOB_INVALID_PROGRESS";
108}
109
110// =============================================================================
111// Route Configuration
112// =============================================================================
113
114/// Create Axum router with all 16 Backbone endpoints for ConversionJob.
115///
116/// # Routes
117///
118/// | Method | Path | Description |
119/// |--------|------|-------------|
120/// | GET | /conversion_jobs | List with pagination |
121/// | POST | /conversion_jobs | Create new |
122/// | GET | /conversion_jobs/:id | Get by ID |
123/// | PUT | /conversion_jobs/:id | Full update |
124/// | PATCH | /conversion_jobs/:id | Partial update |
125/// | DELETE | /conversion_jobs/:id | Soft delete |
126/// | POST | /conversion_jobs/bulk | Bulk create |
127/// | POST | /conversion_jobs/upsert | Upsert |
128/// | GET | /conversion_jobs/trash | List deleted |
129/// | POST | /conversion_jobs/:id/restore | Restore |
130/// | DELETE | /conversion_jobs/empty | Empty trash |
131/// | GET | /conversion_jobs/:id/deleted | Get deleted by ID |
132/// | DELETE | /conversion_jobs/trash/:id | Permanent delete from trash |
133/// | GET | /conversion_jobs/count | Count active entities |
134/// | GET | /conversion_jobs/trash/count | Count deleted entities |
135///
136/// # Example
137///
138/// ```text
139/// let service = Arc::new(ConversionJobService::with_repository(repository));
140/// let router = create_conversion_job_routes(service);
141/// ```
142pub fn create_conversion_job_routes(service: Arc<ConversionJobService>) -> Router {
143    BackboneCrudHandler::<ConversionJobService, ConversionJob, CreateConversionJobDto, UpdateConversionJobDto, ConversionJobResponseDto>::routes(
144        service,
145        "/conversion_jobs",
146    )
147}
148
149/// Create Axum router with only the read (GET) endpoints for ConversionJob.
150///
151/// Safe for public, unauthenticated exposure (e.g., reference data).
152/// Mutations must be served separately via `create_conversion_job_write_routes`,
153/// typically wrapped in an auth middleware layer.
154pub fn create_conversion_job_read_routes(service: Arc<ConversionJobService>) -> Router {
155    BackboneCrudHandler::<ConversionJobService, ConversionJob, CreateConversionJobDto, UpdateConversionJobDto, ConversionJobResponseDto>::read_routes(
156        service,
157        "/conversion_jobs",
158    )
159}
160
161/// Create Axum router with only the write (mutation) endpoints for ConversionJob.
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_conversion_job_write_routes(service: Arc<ConversionJobService>) -> Router {
173    BackboneCrudHandler::<ConversionJobService, ConversionJob, CreateConversionJobDto, UpdateConversionJobDto, ConversionJobResponseDto>::write_routes(
174        service,
175        "/conversion_jobs",
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_conversion_job_routes<A: AuthMiddleware + Send + Sync + 'static>(
186    service: Arc<ConversionJobService>,
187    auth: Arc<A>,
188) -> Router {
189    use axum::middleware;
190    use axum::response::IntoResponse;
191
192    let auth_layer = auth.clone();
193    create_conversion_job_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 start transition on a ConversionJob.
226///
227/// POST /conversion_jobs/:id/transitions/start
228pub async fn start_transition(
229    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
239        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
240    };
241
242    // Check permission (if auth enabled)
243    #[cfg(feature = "auth")]
244    {
245        let allowed_roles = ConversionJobTransition::Start.allowed_roles();
246        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:start");
247        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for start transition")));
251        }
252    }
253
254    // Create state machine from entity's actual status and validate transition
255    let current_state: ConversionJobState = entity.status.to_string().parse()
256        .unwrap_or(ConversionJobState::default());
257    let sm = ConversionJobStateMachine::from_state(current_state);
258    if !sm.can_transition(ConversionJobTransition::Start) {
259        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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: ConversionJobResponseDto = updated.into();
269            (StatusCode::OK, Json(ApiResponse::ok(response)))
270        }
271        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
272        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
273    }
274}
275
276/// Execute update_progress transition on a ConversionJob.
277///
278/// POST /conversion_jobs/:id/transitions/update_progress
279pub async fn update_progress_transition(
280    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
290        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
291    };
292
293    // Check permission (if auth enabled)
294    #[cfg(feature = "auth")]
295    {
296        let allowed_roles = ConversionJobTransition::UpdateProgress.allowed_roles();
297        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:update_progress");
298        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for update_progress transition")));
302        }
303    }
304
305    // Create state machine from entity's actual status and validate transition
306    let current_state: ConversionJobState = entity.status.to_string().parse()
307        .unwrap_or(ConversionJobState::default());
308    let sm = ConversionJobStateMachine::from_state(current_state);
309    if !sm.can_transition(ConversionJobTransition::UpdateProgress) {
310        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Processing".to_string()));
316
317    match service.partial_update(&id, fields).await {
318        Ok(Some(updated)) => {
319            let response: ConversionJobResponseDto = updated.into();
320            (StatusCode::OK, Json(ApiResponse::ok(response)))
321        }
322        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
323        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
324    }
325}
326
327/// Execute complete transition on a ConversionJob.
328///
329/// POST /conversion_jobs/:id/transitions/complete
330pub async fn complete_transition(
331    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
341        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
342    };
343
344    // Check permission (if auth enabled)
345    #[cfg(feature = "auth")]
346    {
347        let allowed_roles = ConversionJobTransition::Complete.allowed_roles();
348        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:complete");
349        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for complete transition")));
353        }
354    }
355
356    // Create state machine from entity's actual status and validate transition
357    let current_state: ConversionJobState = entity.status.to_string().parse()
358        .unwrap_or(ConversionJobState::default());
359    let sm = ConversionJobStateMachine::from_state(current_state);
360    if !sm.can_transition(ConversionJobTransition::Complete) {
361        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Completed".to_string()));
367
368    match service.partial_update(&id, fields).await {
369        Ok(Some(updated)) => {
370            let response: ConversionJobResponseDto = updated.into();
371            (StatusCode::OK, Json(ApiResponse::ok(response)))
372        }
373        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
374        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
375    }
376}
377
378/// Execute fail transition on a ConversionJob.
379///
380/// POST /conversion_jobs/:id/transitions/fail
381pub async fn fail_transition(
382    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
392        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
393    };
394
395    // Check permission (if auth enabled)
396    #[cfg(feature = "auth")]
397    {
398        let allowed_roles = ConversionJobTransition::Fail.allowed_roles();
399        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:fail");
400        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for fail transition")));
404        }
405    }
406
407    // Create state machine from entity's actual status and validate transition
408    let current_state: ConversionJobState = entity.status.to_string().parse()
409        .unwrap_or(ConversionJobState::default());
410    let sm = ConversionJobStateMachine::from_state(current_state);
411    if !sm.can_transition(ConversionJobTransition::Fail) {
412        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Failed".to_string()));
418
419    match service.partial_update(&id, fields).await {
420        Ok(Some(updated)) => {
421            let response: ConversionJobResponseDto = updated.into();
422            (StatusCode::OK, Json(ApiResponse::ok(response)))
423        }
424        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
425        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
426    }
427}
428
429/// Execute cancel_pending transition on a ConversionJob.
430///
431/// POST /conversion_jobs/:id/transitions/cancel_pending
432pub async fn cancel_pending_transition(
433    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
443        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
444    };
445
446    // Check permission (if auth enabled)
447    #[cfg(feature = "auth")]
448    {
449        let allowed_roles = ConversionJobTransition::CancelPending.allowed_roles();
450        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:cancel_pending");
451        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for cancel_pending transition")));
455        }
456    }
457
458    // Create state machine from entity's actual status and validate transition
459    let current_state: ConversionJobState = entity.status.to_string().parse()
460        .unwrap_or(ConversionJobState::default());
461    let sm = ConversionJobStateMachine::from_state(current_state);
462    if !sm.can_transition(ConversionJobTransition::CancelPending) {
463        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Cancelled".to_string()));
469
470    match service.partial_update(&id, fields).await {
471        Ok(Some(updated)) => {
472            let response: ConversionJobResponseDto = updated.into();
473            (StatusCode::OK, Json(ApiResponse::ok(response)))
474        }
475        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
476        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
477    }
478}
479
480/// Execute cancel_running transition on a ConversionJob.
481///
482/// POST /conversion_jobs/:id/transitions/cancel_running
483pub async fn cancel_running_transition(
484    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
494        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
495    };
496
497    // Check permission (if auth enabled)
498    #[cfg(feature = "auth")]
499    {
500        let allowed_roles = ConversionJobTransition::CancelRunning.allowed_roles();
501        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:cancel_running");
502        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for cancel_running transition")));
506        }
507    }
508
509    // Create state machine from entity's actual status and validate transition
510    let current_state: ConversionJobState = entity.status.to_string().parse()
511        .unwrap_or(ConversionJobState::default());
512    let sm = ConversionJobStateMachine::from_state(current_state);
513    if !sm.can_transition(ConversionJobTransition::CancelRunning) {
514        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Cancelled".to_string()));
520
521    match service.partial_update(&id, fields).await {
522        Ok(Some(updated)) => {
523            let response: ConversionJobResponseDto = updated.into();
524            (StatusCode::OK, Json(ApiResponse::ok(response)))
525        }
526        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
527        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
528    }
529}
530
531/// Execute retry transition on a ConversionJob.
532///
533/// POST /conversion_jobs/:id/transitions/retry
534pub async fn retry_transition(
535    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
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::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
545        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
546    };
547
548    // Check permission (if auth enabled)
549    #[cfg(feature = "auth")]
550    {
551        let allowed_roles = ConversionJobTransition::Retry.allowed_roles();
552        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:retry");
553        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job: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::<ConversionJobResponseDto>::error("Insufficient permissions for retry transition")));
557        }
558    }
559
560    // Create state machine from entity's actual status and validate transition
561    let current_state: ConversionJobState = entity.status.to_string().parse()
562        .unwrap_or(ConversionJobState::default());
563    let sm = ConversionJobStateMachine::from_state(current_state);
564    if !sm.can_transition(ConversionJobTransition::Retry) {
565        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::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("Pending".to_string()));
571
572    match service.partial_update(&id, fields).await {
573        Ok(Some(updated)) => {
574            let response: ConversionJobResponseDto = updated.into();
575            (StatusCode::OK, Json(ApiResponse::ok(response)))
576        }
577        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
578        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
579    }
580}
581
582/// Execute reconvert transition on a ConversionJob.
583///
584/// POST /conversion_jobs/:id/transitions/reconvert
585pub async fn reconvert_transition(
586    axum::extract::State(service): axum::extract::State<Arc<ConversionJobService>>,
587    axum::extract::Path(id): axum::extract::Path<String>,
588    #[cfg(feature = "auth")] axum::Extension(auth): axum::Extension<AuthContext>,
589) -> impl axum::response::IntoResponse {
590    use axum::{http::StatusCode, Json};
591
592    // Get current entity
593    let entity = match service.get_by_id(&id).await {
594        Ok(Some(e)) => e,
595        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
596        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
597    };
598
599    // Check permission (if auth enabled)
600    #[cfg(feature = "auth")]
601    {
602        let allowed_roles = ConversionJobTransition::Reconvert.allowed_roles();
603        let has_specific_perm = auth.permissions.iter().any(|p| p == "conversion_job:transition:reconvert");
604        let has_update_perm = auth.permissions.iter().any(|p| p == "conversion_job:update");
605        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
606        if !has_specific_perm && !has_update_perm && !has_role {
607            return (StatusCode::FORBIDDEN, Json(ApiResponse::<ConversionJobResponseDto>::error("Insufficient permissions for reconvert transition")));
608        }
609    }
610
611    // Create state machine from entity's actual status and validate transition
612    let current_state: ConversionJobState = entity.status.to_string().parse()
613        .unwrap_or(ConversionJobState::default());
614    let sm = ConversionJobStateMachine::from_state(current_state);
615    if !sm.can_transition(ConversionJobTransition::Reconvert) {
616        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<ConversionJobResponseDto>::error("Transition not allowed from current state")));
617    }
618
619    // Apply transition via partial update
620    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
621    fields.insert("status".to_string(), serde_json::Value::String("Pending".to_string()));
622
623    match service.partial_update(&id, fields).await {
624        Ok(Some(updated)) => {
625            let response: ConversionJobResponseDto = updated.into();
626            (StatusCode::OK, Json(ApiResponse::ok(response)))
627        }
628        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<ConversionJobResponseDto>::not_found("ConversionJob", &id))),
629        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<ConversionJobResponseDto>::error(e.to_string()))),
630    }
631}
632
633/// Create routes for state transitions.
634pub fn create_conversion_job_transition_routes(service: Arc<ConversionJobService>) -> Router {
635    use axum::routing::post;
636
637    Router::new()
638        .route("/conversion_jobs/:id/transitions/start", post(start_transition))
639        .route("/conversion_jobs/:id/transitions/update_progress", post(update_progress_transition))
640        .route("/conversion_jobs/:id/transitions/complete", post(complete_transition))
641        .route("/conversion_jobs/:id/transitions/fail", post(fail_transition))
642        .route("/conversion_jobs/:id/transitions/cancel_pending", post(cancel_pending_transition))
643        .route("/conversion_jobs/:id/transitions/cancel_running", post(cancel_running_transition))
644        .route("/conversion_jobs/:id/transitions/retry", post(retry_transition))
645        .route("/conversion_jobs/:id/transitions/reconvert", post(reconvert_transition))
646        .with_state(service)
647}