1use std::collections::HashMap;
8use std::sync::Arc;
9
10use axum::Router;
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13use chrono::{DateTime, Utc};
14
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
17
18#[cfg(feature = "auth")]
20use backbone_auth::middleware::AuthContext;
21#[cfg(feature = "auth")]
22use backbone_auth::AuthMiddleware;
23
24use crate::domain::entity::*;
26use crate::application::service::{ConversionJobService, ServiceError};
27
28use crate::presentation::dto::{CreateConversionJobDto, UpdateConversionJobDto, PatchConversionJobDto, ConversionJobResponseDto};
30
31use crate::domain::state_machine::{ConversionJobState, ConversionJobStateMachine, ConversionJobTransition};
32
33#[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 #[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
100pub 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
110pub 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
149pub 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
161pub 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#[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
221pub 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 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 #[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 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 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
276pub 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 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 #[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 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 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
327pub 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 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 #[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 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 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
378pub 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 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 #[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 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 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
429pub 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 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 #[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 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 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
480pub 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 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 #[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 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 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
531pub 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 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 #[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 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 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
582pub 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 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 #[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 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 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
633pub 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}