Skip to main content

backbone_bucket/presentation/http/
bucket_handler.rs

1//! Bucket 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;
13
14// Backbone framework imports
15use backbone_core::http::{ApiResponse, BackboneCrudHandler};
16
17// Auth integration (optional)
18use backbone_auth::middleware::AuthContext;
19#[cfg(feature = "auth")]
20use backbone_auth::AuthMiddleware;
21
22// Domain imports
23use crate::domain::entity::*;
24use crate::application::service::{BucketService, ServiceError};
25
26// DTO imports
27use crate::presentation::dto::{CreateBucketDto, UpdateBucketDto, PatchBucketDto, BucketResponseDto};
28
29use crate::domain::state_machine::{BucketState, BucketStateMachine, BucketTransition};
30
31/// Application error type
32#[derive(Debug, thiserror::Error)]
33pub enum BucketError {
34    #[error("Not found: {0}")]
35    NotFound(String),
36    #[error("Validation error: {0}")]
37    Validation(String),
38    #[error("Database error: {0}")]
39    Database(String),
40    #[error("Internal error: {0}")]
41    Internal(String),
42    // Domain-specific errors from hook rules
43    #[error("Bucket name must be 1-255 characters: {0}")]
44    InvalidNameLength(String),
45    #[error("Bucket name must contain only alphanumeric characters, underscores, and hyphens: {0}")]
46    InvalidNameChars(String),
47    #[error("Bucket slug must be 1-255 characters: {0}")]
48    InvalidSlugLength(String),
49    #[error("Bucket slug must be lowercase with hyphens only: {0}")]
50    InvalidSlugChars(String),
51    #[error("Root path too long: {0}")]
52    PathTooLong(String),
53    #[error("Path traversal not allowed in root path: {0}")]
54    PathTraversal(String),
55    #[error("Cannot modify deleted buckets: {0}")]
56    BucketDeleted(String),
57    #[error("Bucket slug is already taken: {0}")]
58    SlugExists(String),
59}
60
61impl From<ServiceError> for BucketError {
62    fn from(err: ServiceError) -> Self {
63        match err {
64            ServiceError::NotFound => Self::NotFound(err.to_string()),
65            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
66            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
67            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
68            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
69            ServiceError::Violations(_) => Self::Validation(err.to_string()),
70        }
71    }
72}
73
74impl axum::response::IntoResponse for BucketError {
75    fn into_response(self) -> axum::response::Response {
76        use axum::http::StatusCode;
77        use axum::Json;
78
79        let (status, code) = match &self {
80            Self::NotFound(_) => (StatusCode::NOT_FOUND, "BUCKET_NOT_FOUND"),
81            Self::Validation(_) => (StatusCode::BAD_REQUEST, "BUCKET_VALIDATION_ERROR"),
82            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "BUCKET_DATABASE_ERROR"),
83            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "BUCKET_INTERNAL_ERROR"),
84            Self::InvalidNameLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_NAME_LENGTH"),
85            Self::InvalidNameChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_NAME_CHARS"),
86            Self::InvalidSlugLength(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_SLUG_LENGTH"),
87            Self::InvalidSlugChars(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_INVALID_SLUG_CHARS"),
88            Self::PathTooLong(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_PATH_TOO_LONG"),
89            Self::PathTraversal(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_PATH_TRAVERSAL"),
90            Self::BucketDeleted(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_BUCKET_DELETED"),
91            Self::SlugExists(_) => (StatusCode::UNPROCESSABLE_ENTITY, "BUCKET_SLUG_EXISTS"),
92        };
93
94        let body = serde_json::json!({
95            "success": false,
96            "error": code,
97            "message": self.to_string(),
98        });
99
100        (status, Json(body)).into_response()
101    }
102}
103
104/// Domain-specific error codes for Bucket
105pub mod bucket_errors {
106    pub const INVALID_NAME_LENGTH: &str = "BUCKET_INVALID_NAME_LENGTH";
107    pub const INVALID_NAME_CHARS: &str = "BUCKET_INVALID_NAME_CHARS";
108    pub const INVALID_SLUG_LENGTH: &str = "BUCKET_INVALID_SLUG_LENGTH";
109    pub const INVALID_SLUG_CHARS: &str = "BUCKET_INVALID_SLUG_CHARS";
110    pub const PATH_TOO_LONG: &str = "BUCKET_PATH_TOO_LONG";
111    pub const PATH_TRAVERSAL: &str = "BUCKET_PATH_TRAVERSAL";
112    pub const BUCKET_DELETED: &str = "BUCKET_BUCKET_DELETED";
113    pub const SLUG_EXISTS: &str = "BUCKET_SLUG_EXISTS";
114}
115
116// =============================================================================
117// Route Configuration
118// =============================================================================
119
120/// Create Axum router with all 16 Backbone endpoints for Bucket.
121///
122/// # Routes
123///
124/// | Method | Path | Description |
125/// |--------|------|-------------|
126/// | GET | /buckets | List with pagination |
127/// | POST | /buckets | Create new |
128/// | GET | /buckets/:id | Get by ID |
129/// | PUT | /buckets/:id | Full update |
130/// | PATCH | /buckets/:id | Partial update |
131/// | DELETE | /buckets/:id | Soft delete |
132/// | POST | /buckets/bulk | Bulk create |
133/// | POST | /buckets/upsert | Upsert |
134/// | GET | /buckets/trash | List deleted |
135/// | POST | /buckets/:id/restore | Restore |
136/// | DELETE | /buckets/empty | Empty trash |
137/// | GET | /buckets/:id/deleted | Get deleted by ID |
138/// | DELETE | /buckets/trash/:id | Permanent delete from trash |
139/// | GET | /buckets/count | Count active entities |
140/// | GET | /buckets/trash/count | Count deleted entities |
141///
142/// # Example
143///
144/// ```text
145/// let service = Arc::new(BucketService::with_repository(repository));
146/// let router = create_bucket_routes(service);
147/// ```
148pub fn create_bucket_routes(service: Arc<BucketService>) -> Router {
149    BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::routes(
150        service,
151        "/buckets",
152    )
153}
154
155/// Create Axum router with only the read (GET) endpoints for Bucket.
156///
157/// Safe for public, unauthenticated exposure (e.g., reference data).
158/// Mutations must be served separately via `create_bucket_write_routes`,
159/// typically wrapped in an auth middleware layer.
160pub fn create_bucket_read_routes(service: Arc<BucketService>) -> Router {
161    BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::read_routes(
162        service,
163        "/buckets",
164    )
165}
166
167/// Create Axum router with only the write (mutation) endpoints for Bucket.
168///
169/// These routes must NOT be publicly exposed. Wrap them with an auth
170/// middleware before nesting into the application router.
171///
172/// # This is unguarded generic CRUD, not a validated write path
173///
174/// These are plain create/update/patch/delete mutations over the entity row —
175/// they bypass all business invariants. If the module exposes a validated write
176/// service (e.g. a command router over its domain engine), serve THAT instead
177/// for any mutation that must respect domain rules.
178pub fn create_bucket_write_routes(service: Arc<BucketService>) -> Router {
179    BackboneCrudHandler::<BucketService, Bucket, CreateBucketDto, UpdateBucketDto, BucketResponseDto>::write_routes(
180        service,
181        "/buckets",
182    )
183}
184
185/// Create authenticated routes with auth middleware.
186///
187/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
188/// is responsible for extracting and validating tokens, then providing
189/// an `AuthContext` via request extensions.
190#[cfg(feature = "auth")]
191pub fn create_protected_bucket_routes<A: AuthMiddleware + Send + Sync + 'static>(
192    service: Arc<BucketService>,
193    auth: Arc<A>,
194) -> Router {
195    use axum::middleware;
196    use axum::response::IntoResponse;
197
198    let auth_layer = auth.clone();
199    create_bucket_routes(service)
200        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
201            let auth = auth_layer.clone();
202            async move {
203                let token = req.headers()
204                    .get(axum::http::header::AUTHORIZATION)
205                    .and_then(|h| h.to_str().ok())
206                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
207                    .unwrap_or("");
208                match auth.authenticate(token).await {
209                    Ok(ctx) => {
210                        req.extensions_mut().insert(ctx);
211                        next.run(req).await
212                    }
213                    Err(_) => {
214                        (axum::http::StatusCode::UNAUTHORIZED,
215                         axum::Json(serde_json::json!({
216                             "success": false,
217                             "error": "unauthorized",
218                             "message": "Authentication required"
219                         }))
220                        ).into_response()
221                    }
222                }
223            }
224        }))
225}
226
227// =============================================================================
228// State Transition Handlers
229// =============================================================================
230
231/// Execute lock transition on a Bucket.
232///
233/// POST /buckets/:id/transitions/lock
234pub async fn lock_transition(
235    axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
236    axum::extract::Path(id): axum::extract::Path<String>,
237    auth: Option<axum::Extension<AuthContext>>,
238) -> impl axum::response::IntoResponse {
239    use axum::{http::StatusCode, Json};
240
241    // Get current entity
242    let entity = match service.get_by_id(&id).await {
243        Ok(Some(e)) => e,
244        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
245        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
246    };
247
248    // Check permission
249    let Some(axum::Extension(auth)) = auth else {
250        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
251    };
252    {
253        let allowed_roles = BucketTransition::Lock.allowed_roles();
254        let granted = |required: &str| auth.permissions.iter().any(|p| {
255            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
256            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
257                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
258        });
259        let has_specific_perm = granted("bucket:transition:lock");
260        let has_update_perm = granted("bucket:update");
261        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
262        if !has_specific_perm && !has_role {
263            if !has_update_perm {
264                return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for lock transition")));
265            }
266            tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "lock", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:lock or one of the roles admin, owner");
267        }
268    }
269
270    // Create state machine from entity's actual status and validate transition
271    let current_state: BucketState = match entity.status.to_string().parse() {
272        Ok(s) => s,
273        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
274    };
275    let sm = BucketStateMachine::from_state(current_state);
276    if !sm.can_transition(BucketTransition::Lock) {
277        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
278    }
279
280    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
281    // Apply transition via partial update
282    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
283    fields.insert("status".to_string(), serde_json::Value::String("readonly".to_string()));
284
285    match backbone_core::write_guard::as_transition("lock", service.partial_update(&id, fields)).await {
286        Ok(Some(updated)) => {
287            let response: BucketResponseDto = updated.into();
288            (StatusCode::OK, Json(ApiResponse::ok(response)))
289        }
290        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
291        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
292    }
293}
294
295/// Execute unlock transition on a Bucket.
296///
297/// POST /buckets/:id/transitions/unlock
298pub async fn unlock_transition(
299    axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
300    axum::extract::Path(id): axum::extract::Path<String>,
301    auth: Option<axum::Extension<AuthContext>>,
302) -> impl axum::response::IntoResponse {
303    use axum::{http::StatusCode, Json};
304
305    // Get current entity
306    let entity = match service.get_by_id(&id).await {
307        Ok(Some(e)) => e,
308        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
309        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
310    };
311
312    // Check permission
313    let Some(axum::Extension(auth)) = auth else {
314        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
315    };
316    {
317        let allowed_roles = BucketTransition::Unlock.allowed_roles();
318        let granted = |required: &str| auth.permissions.iter().any(|p| {
319            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
320            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
321                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
322        });
323        let has_specific_perm = granted("bucket:transition:unlock");
324        let has_update_perm = granted("bucket:update");
325        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
326        if !has_specific_perm && !has_role {
327            if !has_update_perm {
328                return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for unlock transition")));
329            }
330            tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "unlock", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:unlock or one of the roles admin, owner");
331        }
332    }
333
334    // Create state machine from entity's actual status and validate transition
335    let current_state: BucketState = match entity.status.to_string().parse() {
336        Ok(s) => s,
337        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
338    };
339    let sm = BucketStateMachine::from_state(current_state);
340    if !sm.can_transition(BucketTransition::Unlock) {
341        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
342    }
343
344    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
345    // Apply transition via partial update
346    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
347    fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
348
349    match backbone_core::write_guard::as_transition("unlock", service.partial_update(&id, fields)).await {
350        Ok(Some(updated)) => {
351            let response: BucketResponseDto = updated.into();
352            (StatusCode::OK, Json(ApiResponse::ok(response)))
353        }
354        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
355        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
356    }
357}
358
359/// Execute archive transition on a Bucket.
360///
361/// POST /buckets/:id/transitions/archive
362pub async fn archive_transition(
363    axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
364    axum::extract::Path(id): axum::extract::Path<String>,
365    auth: Option<axum::Extension<AuthContext>>,
366) -> impl axum::response::IntoResponse {
367    use axum::{http::StatusCode, Json};
368
369    // Get current entity
370    let entity = match service.get_by_id(&id).await {
371        Ok(Some(e)) => e,
372        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
373        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
374    };
375
376    // Check permission
377    let Some(axum::Extension(auth)) = auth else {
378        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
379    };
380    {
381        let allowed_roles = BucketTransition::Archive.allowed_roles();
382        let granted = |required: &str| auth.permissions.iter().any(|p| {
383            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
384            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
385                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
386        });
387        let has_specific_perm = granted("bucket:transition:archive");
388        let has_update_perm = granted("bucket:update");
389        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
390        if !has_specific_perm && !has_role {
391            if !has_update_perm {
392                return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for archive transition")));
393            }
394            tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "archive", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:archive or one of the roles admin, owner");
395        }
396    }
397
398    // Create state machine from entity's actual status and validate transition
399    let current_state: BucketState = match entity.status.to_string().parse() {
400        Ok(s) => s,
401        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
402    };
403    let sm = BucketStateMachine::from_state(current_state);
404    if !sm.can_transition(BucketTransition::Archive) {
405        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
406    }
407
408    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
409    // Apply transition via partial update
410    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
411    fields.insert("status".to_string(), serde_json::Value::String("archived".to_string()));
412
413    match backbone_core::write_guard::as_transition("archive", service.partial_update(&id, fields)).await {
414        Ok(Some(updated)) => {
415            let response: BucketResponseDto = updated.into();
416            (StatusCode::OK, Json(ApiResponse::ok(response)))
417        }
418        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
419        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
420    }
421}
422
423/// Execute restore transition on a Bucket.
424///
425/// POST /buckets/:id/transitions/restore
426pub async fn restore_transition(
427    axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
428    axum::extract::Path(id): axum::extract::Path<String>,
429    auth: Option<axum::Extension<AuthContext>>,
430) -> impl axum::response::IntoResponse {
431    use axum::{http::StatusCode, Json};
432
433    // Get current entity
434    let entity = match service.get_by_id(&id).await {
435        Ok(Some(e)) => e,
436        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
437        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
438    };
439
440    // Check permission
441    let Some(axum::Extension(auth)) = auth else {
442        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
443    };
444    {
445        let allowed_roles = BucketTransition::Restore.allowed_roles();
446        let granted = |required: &str| auth.permissions.iter().any(|p| {
447            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
448            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
449                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
450        });
451        let has_specific_perm = granted("bucket:transition:restore");
452        let has_update_perm = granted("bucket:update");
453        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
454        if !has_specific_perm && !has_role {
455            if !has_update_perm {
456                return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for restore transition")));
457            }
458            tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "restore", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:restore or one of the roles admin, owner");
459        }
460    }
461
462    // Create state machine from entity's actual status and validate transition
463    let current_state: BucketState = match entity.status.to_string().parse() {
464        Ok(s) => s,
465        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
466    };
467    let sm = BucketStateMachine::from_state(current_state);
468    if !sm.can_transition(BucketTransition::Restore) {
469        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
470    }
471
472    // Guard `$actor.id == owner_id || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
473    // Apply transition via partial update
474    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
475    fields.insert("status".to_string(), serde_json::Value::String("active".to_string()));
476
477    match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
478        Ok(Some(updated)) => {
479            let response: BucketResponseDto = updated.into();
480            (StatusCode::OK, Json(ApiResponse::ok(response)))
481        }
482        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
483        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
484    }
485}
486
487/// Execute delete transition on a Bucket.
488///
489/// POST /buckets/:id/transitions/delete
490pub async fn delete_transition(
491    axum::extract::State(service): axum::extract::State<Arc<BucketService>>,
492    axum::extract::Path(id): axum::extract::Path<String>,
493    auth: Option<axum::Extension<AuthContext>>,
494) -> impl axum::response::IntoResponse {
495    use axum::{http::StatusCode, Json};
496
497    // Get current entity
498    let entity = match service.get_by_id(&id).await {
499        Ok(Some(e)) => e,
500        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
501        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
502    };
503
504    // Check permission
505    let Some(axum::Extension(auth)) = auth else {
506        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<BucketResponseDto>::error("A transition needs an authenticated caller")));
507    };
508    {
509        let allowed_roles = BucketTransition::Delete.allowed_roles();
510        let granted = |required: &str| auth.permissions.iter().any(|p| {
511            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
512            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
513                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
514        });
515        let has_specific_perm = granted("bucket:transition:delete");
516        let has_update_perm = granted("bucket:update");
517        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
518        if !has_specific_perm && !has_role {
519            if !has_update_perm {
520                return (StatusCode::FORBIDDEN, Json(ApiResponse::<BucketResponseDto>::error("Insufficient permissions for delete transition")));
521            }
522            tracing::warn!(target: "backbone::write_guard", entity = "Bucket", verb = "delete", code = "transition_by_update_permission", "a transition granted only by bucket:update, running in shadow; it needs bucket:transition:delete or one of the roles admin, owner");
523        }
524    }
525
526    // Create state machine from entity's actual status and validate transition
527    let current_state: BucketState = match entity.status.to_string().parse() {
528        Ok(s) => s,
529        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<BucketResponseDto>::error("The stored state is not a state of this machine"))),
530    };
531    let sm = BucketStateMachine::from_state(current_state);
532    if !sm.can_transition(BucketTransition::Delete) {
533        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<BucketResponseDto>::error("Transition not allowed from current state")));
534    }
535
536    // Guard `file_count == 0 || $actor.has_role('admin')` not compiled (reads the writer, the time or the row before the write); the transition is not checked against it.
537    // Apply transition via partial update
538    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
539    fields.insert("status".to_string(), serde_json::Value::String("deleted".to_string()));
540
541    match backbone_core::write_guard::as_transition("delete", service.partial_update(&id, fields)).await {
542        Ok(Some(updated)) => {
543            let response: BucketResponseDto = updated.into();
544            (StatusCode::OK, Json(ApiResponse::ok(response)))
545        }
546        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<BucketResponseDto>::not_found("Bucket", &id))),
547        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<BucketResponseDto>::error(e.to_string()))),
548    }
549}
550
551/// Create routes for state transitions.
552pub fn create_bucket_transition_routes(service: Arc<BucketService>) -> Router {
553    use axum::routing::post;
554
555    Router::new()
556        .route("/buckets/:id/transitions/lock", post(lock_transition))
557        .route("/buckets/:id/transitions/unlock", post(unlock_transition))
558        .route("/buckets/:id/transitions/archive", post(archive_transition))
559        .route("/buckets/:id/transitions/restore", post(restore_transition))
560        .route("/buckets/:id/transitions/delete", post(delete_transition))
561        .with_state(service)
562}
563