Skip to main content

backbone_bucket/presentation/http/
user_quota_handler.rs

1//! UserQuota 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::{UserQuotaService, ServiceError};
26
27// DTO imports
28use crate::presentation::dto::{CreateUserQuotaDto, UpdateUserQuotaDto, PatchUserQuotaDto, UserQuotaResponseDto};
29
30use crate::domain::state_machine::{UserQuotaState, UserQuotaStateMachine, UserQuotaTransition};
31
32/// Application error type
33#[derive(Debug, thiserror::Error)]
34pub enum UserQuotaError {
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("User does not exist: {0}")]
45    UserNotFound(String),
46    #[error("User already has a quota record: {0}")]
47    QuotaExists(String),
48    #[error("Invalid tier. Must be free, pro, or enterprise: {0}")]
49    InvalidTier(String),
50    #[error("Max storage must be positive: {0}")]
51    InvalidMaxStorage(String),
52    #[error("Max file size must be positive: {0}")]
53    InvalidMaxFileSize(String),
54    #[error("Max file size cannot exceed max storage: {0}")]
55    FileSizeExceedsStorage(String),
56    #[error("Cannot downgrade tier: current usage exceeds new quota limits: {0}")]
57    DowngradeNotAllowed(String),
58    #[error("Warning threshold must be between 50 and 99 percent: {0}")]
59    InvalidWarningThreshold(String),
60}
61
62impl From<ServiceError> for UserQuotaError {
63    fn from(err: ServiceError) -> Self {
64        match err {
65            ServiceError::NotFound => Self::NotFound(err.to_string()),
66            ServiceError::Validation(ref msg) => Self::Validation(msg.clone()),
67            ServiceError::AlreadyExists(ref msg) => Self::Validation(msg.clone()),
68            ServiceError::Repository(ref e) => Self::Database(e.to_string()),
69            ServiceError::Internal(ref msg) => Self::Internal(msg.clone()),
70            ServiceError::Violations(_) => Self::Validation(err.to_string()),
71        }
72    }
73}
74
75impl axum::response::IntoResponse for UserQuotaError {
76    fn into_response(self) -> axum::response::Response {
77        use axum::http::StatusCode;
78        use axum::Json;
79
80        let (status, code) = match &self {
81            Self::NotFound(_) => (StatusCode::NOT_FOUND, "USERQUOTA_NOT_FOUND"),
82            Self::Validation(_) => (StatusCode::BAD_REQUEST, "USERQUOTA_VALIDATION_ERROR"),
83            Self::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "USERQUOTA_DATABASE_ERROR"),
84            Self::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "USERQUOTA_INTERNAL_ERROR"),
85            Self::UserNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_USER_NOT_FOUND"),
86            Self::QuotaExists(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_QUOTA_EXISTS"),
87            Self::InvalidTier(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_INVALID_TIER"),
88            Self::InvalidMaxStorage(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_INVALID_MAX_STORAGE"),
89            Self::InvalidMaxFileSize(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_INVALID_MAX_FILE_SIZE"),
90            Self::FileSizeExceedsStorage(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_FILE_SIZE_EXCEEDS_STORAGE"),
91            Self::DowngradeNotAllowed(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_DOWNGRADE_NOT_ALLOWED"),
92            Self::InvalidWarningThreshold(_) => (StatusCode::UNPROCESSABLE_ENTITY, "USERQUOTA_INVALID_WARNING_THRESHOLD"),
93        };
94
95        let body = serde_json::json!({
96            "success": false,
97            "error": code,
98            "message": self.to_string(),
99        });
100
101        (status, Json(body)).into_response()
102    }
103}
104
105/// Domain-specific error codes for UserQuota
106pub mod user_quota_errors {
107    pub const USER_NOT_FOUND: &str = "USERQUOTA_USER_NOT_FOUND";
108    pub const QUOTA_EXISTS: &str = "USERQUOTA_QUOTA_EXISTS";
109    pub const INVALID_TIER: &str = "USERQUOTA_INVALID_TIER";
110    pub const INVALID_MAX_STORAGE: &str = "USERQUOTA_INVALID_MAX_STORAGE";
111    pub const INVALID_MAX_FILE_SIZE: &str = "USERQUOTA_INVALID_MAX_FILE_SIZE";
112    pub const FILE_SIZE_EXCEEDS_STORAGE: &str = "USERQUOTA_FILE_SIZE_EXCEEDS_STORAGE";
113    pub const DOWNGRADE_NOT_ALLOWED: &str = "USERQUOTA_DOWNGRADE_NOT_ALLOWED";
114    pub const INVALID_WARNING_THRESHOLD: &str = "USERQUOTA_INVALID_WARNING_THRESHOLD";
115}
116
117// =============================================================================
118// Route Configuration
119// =============================================================================
120
121/// Create Axum router with all 16 Backbone endpoints for UserQuota.
122///
123/// # Routes
124///
125/// | Method | Path | Description |
126/// |--------|------|-------------|
127/// | GET | /user_quotas | List with pagination |
128/// | POST | /user_quotas | Create new |
129/// | GET | /user_quotas/:id | Get by ID |
130/// | PUT | /user_quotas/:id | Full update |
131/// | PATCH | /user_quotas/:id | Partial update |
132/// | DELETE | /user_quotas/:id | Soft delete |
133/// | POST | /user_quotas/bulk | Bulk create |
134/// | POST | /user_quotas/upsert | Upsert |
135/// | GET | /user_quotas/trash | List deleted |
136/// | POST | /user_quotas/:id/restore | Restore |
137/// | DELETE | /user_quotas/empty | Empty trash |
138/// | GET | /user_quotas/:id/deleted | Get deleted by ID |
139/// | DELETE | /user_quotas/trash/:id | Permanent delete from trash |
140/// | GET | /user_quotas/count | Count active entities |
141/// | GET | /user_quotas/trash/count | Count deleted entities |
142///
143/// # Example
144///
145/// ```text
146/// let service = Arc::new(UserQuotaService::with_repository(repository));
147/// let router = create_user_quota_routes(service);
148/// ```
149pub fn create_user_quota_routes(service: Arc<UserQuotaService>) -> Router {
150    BackboneCrudHandler::<UserQuotaService, UserQuota, CreateUserQuotaDto, UpdateUserQuotaDto, UserQuotaResponseDto>::routes(
151        service,
152        "/user_quotas",
153    )
154}
155
156/// Create Axum router with only the read (GET) endpoints for UserQuota.
157///
158/// Safe for public, unauthenticated exposure (e.g., reference data).
159/// Mutations must be served separately via `create_user_quota_write_routes`,
160/// typically wrapped in an auth middleware layer.
161pub fn create_user_quota_read_routes(service: Arc<UserQuotaService>) -> Router {
162    BackboneCrudHandler::<UserQuotaService, UserQuota, CreateUserQuotaDto, UpdateUserQuotaDto, UserQuotaResponseDto>::read_routes(
163        service,
164        "/user_quotas",
165    )
166}
167
168/// Create Axum router with only the write (mutation) endpoints for UserQuota.
169///
170/// These routes must NOT be publicly exposed. Wrap them with an auth
171/// middleware before nesting into the application router.
172///
173/// # This is unguarded generic CRUD, not a validated write path
174///
175/// These are plain create/update/patch/delete mutations over the entity row —
176/// they bypass all business invariants. If the module exposes a validated write
177/// service (e.g. a command router over its domain engine), serve THAT instead
178/// for any mutation that must respect domain rules.
179pub fn create_user_quota_write_routes(service: Arc<UserQuotaService>) -> Router {
180    BackboneCrudHandler::<UserQuotaService, UserQuota, CreateUserQuotaDto, UpdateUserQuotaDto, UserQuotaResponseDto>::write_routes(
181        service,
182        "/user_quotas",
183    )
184}
185
186/// Create authenticated routes with auth middleware.
187///
188/// Requires the `auth` feature flag. The `AuthMiddleware` implementation
189/// is responsible for extracting and validating tokens, then providing
190/// an `AuthContext` via request extensions.
191#[cfg(feature = "auth")]
192pub fn create_protected_user_quota_routes<A: AuthMiddleware + Send + Sync + 'static>(
193    service: Arc<UserQuotaService>,
194    auth: Arc<A>,
195) -> Router {
196    use axum::middleware;
197    use axum::response::IntoResponse;
198
199    let auth_layer = auth.clone();
200    create_user_quota_routes(service)
201        .layer(middleware::from_fn(move |mut req: axum::extract::Request, next: axum::middleware::Next| {
202            let auth = auth_layer.clone();
203            async move {
204                let token = req.headers()
205                    .get(axum::http::header::AUTHORIZATION)
206                    .and_then(|h| h.to_str().ok())
207                    .and_then(|raw| raw.strip_prefix("Bearer ").or_else(|| raw.strip_prefix("bearer ")))
208                    .unwrap_or("");
209                match auth.authenticate(token).await {
210                    Ok(ctx) => {
211                        req.extensions_mut().insert(ctx);
212                        next.run(req).await
213                    }
214                    Err(_) => {
215                        (axum::http::StatusCode::UNAUTHORIZED,
216                         axum::Json(serde_json::json!({
217                             "success": false,
218                             "error": "unauthorized",
219                             "message": "Authentication required"
220                         }))
221                        ).into_response()
222                    }
223                }
224            }
225        }))
226}
227
228// =============================================================================
229// State Transition Handlers
230// =============================================================================
231
232/// Execute exceed transition on a UserQuota.
233///
234/// POST /user_quotas/:id/transitions/exceed
235pub async fn exceed_transition(
236    axum::extract::State(service): axum::extract::State<Arc<UserQuotaService>>,
237    axum::extract::Path(id): axum::extract::Path<String>,
238    auth: Option<axum::Extension<AuthContext>>,
239) -> impl axum::response::IntoResponse {
240    use axum::{http::StatusCode, Json};
241
242    // Get current entity
243    let entity = match service.get_by_id(&id).await {
244        Ok(Some(e)) => e,
245        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UserQuotaResponseDto>::not_found("UserQuota", &id))),
246        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UserQuotaResponseDto>::error(e.to_string()))),
247    };
248
249    // Check permission
250    let Some(axum::Extension(auth)) = auth else {
251        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UserQuotaResponseDto>::error("A transition needs an authenticated caller")));
252    };
253    {
254        let allowed_roles = UserQuotaTransition::Exceed.allowed_roles();
255        let granted = |required: &str| auth.permissions.iter().any(|p| {
256            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
257            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
258                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
259        });
260        let has_specific_perm = granted("user_quota:transition:exceed");
261        let has_update_perm = granted("user_quota:update");
262        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
263        if !has_specific_perm && !has_role {
264            if !has_update_perm {
265                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UserQuotaResponseDto>::error("Insufficient permissions for exceed transition")));
266            }
267            tracing::warn!(target: "backbone::write_guard", entity = "UserQuota", verb = "exceed", code = "transition_by_update_permission", "a transition granted only by user_quota:update, running in shadow; it needs user_quota:transition:exceed or one of the roles system");
268        }
269    }
270
271    // Create state machine from entity's actual status and validate transition
272    let current_state: UserQuotaState = match entity.quota_status.to_string().parse() {
273        Ok(s) => s,
274        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UserQuotaResponseDto>::error("The stored state is not a state of this machine"))),
275    };
276    let sm = UserQuotaStateMachine::from_state(current_state);
277    if !sm.can_transition(UserQuotaTransition::Exceed) {
278        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UserQuotaResponseDto>::error("Transition not allowed from current state")));
279    }
280
281    // Guard `used_storage_bytes >= max_storage_bytes` not compiled (`used_storage_bytes` is not a field of UserQuota); the transition is not checked against it.
282    // Apply transition via partial update
283    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
284    fields.insert("quota_status".to_string(), serde_json::Value::String("exceeded".to_string()));
285
286    match backbone_core::write_guard::as_transition("exceed", service.partial_update(&id, fields)).await {
287        Ok(Some(updated)) => {
288            let response: UserQuotaResponseDto = updated.into();
289            (StatusCode::OK, Json(ApiResponse::ok(response)))
290        }
291        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UserQuotaResponseDto>::not_found("UserQuota", &id))),
292        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UserQuotaResponseDto>::error(e.to_string()))),
293    }
294}
295
296/// Execute restore transition on a UserQuota.
297///
298/// POST /user_quotas/:id/transitions/restore
299pub async fn restore_transition(
300    axum::extract::State(service): axum::extract::State<Arc<UserQuotaService>>,
301    axum::extract::Path(id): axum::extract::Path<String>,
302    auth: Option<axum::Extension<AuthContext>>,
303) -> impl axum::response::IntoResponse {
304    use axum::{http::StatusCode, Json};
305
306    // Get current entity
307    let entity = match service.get_by_id(&id).await {
308        Ok(Some(e)) => e,
309        Ok(None) => return (StatusCode::NOT_FOUND, Json(ApiResponse::<UserQuotaResponseDto>::not_found("UserQuota", &id))),
310        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UserQuotaResponseDto>::error(e.to_string()))),
311    };
312
313    // Check permission
314    let Some(axum::Extension(auth)) = auth else {
315        return (StatusCode::UNAUTHORIZED, Json(ApiResponse::<UserQuotaResponseDto>::error("A transition needs an authenticated caller")));
316    };
317    {
318        let allowed_roles = UserQuotaTransition::Restore.allowed_roles();
319        let granted = |required: &str| auth.permissions.iter().any(|p| {
320            let (have, want): (Vec<&str>, Vec<&str>) = (p.split(':').collect(), required.split(':').collect());
321            have.iter().enumerate().all(|(i, h)| *h == "*" || want.get(i) == Some(h))
322                && (have.len() == want.len() || (have.len() < want.len() && have.last() == Some(&"*")))
323        });
324        let has_specific_perm = granted("user_quota:transition:restore");
325        let has_update_perm = granted("user_quota:update");
326        let has_role = auth.roles.iter().any(|r| allowed_roles.contains(&r.as_str()));
327        if !has_specific_perm && !has_role {
328            if !has_update_perm {
329                return (StatusCode::FORBIDDEN, Json(ApiResponse::<UserQuotaResponseDto>::error("Insufficient permissions for restore transition")));
330            }
331            tracing::warn!(target: "backbone::write_guard", entity = "UserQuota", verb = "restore", code = "transition_by_update_permission", "a transition granted only by user_quota:update, running in shadow; it needs user_quota:transition:restore or one of the roles system");
332        }
333    }
334
335    // Create state machine from entity's actual status and validate transition
336    let current_state: UserQuotaState = match entity.quota_status.to_string().parse() {
337        Ok(s) => s,
338        Err(_) => return (StatusCode::CONFLICT, Json(ApiResponse::<UserQuotaResponseDto>::error("The stored state is not a state of this machine"))),
339    };
340    let sm = UserQuotaStateMachine::from_state(current_state);
341    if !sm.can_transition(UserQuotaTransition::Restore) {
342        return (StatusCode::BAD_REQUEST, Json(ApiResponse::<UserQuotaResponseDto>::error("Transition not allowed from current state")));
343    }
344
345    // Guard `used_storage_bytes < max_storage_bytes` not compiled (`used_storage_bytes` is not a field of UserQuota); the transition is not checked against it.
346    // Apply transition via partial update
347    let mut fields: HashMap<String, serde_json::Value> = HashMap::new();
348    fields.insert("quota_status".to_string(), serde_json::Value::String("normal".to_string()));
349
350    match backbone_core::write_guard::as_transition("restore", service.partial_update(&id, fields)).await {
351        Ok(Some(updated)) => {
352            let response: UserQuotaResponseDto = updated.into();
353            (StatusCode::OK, Json(ApiResponse::ok(response)))
354        }
355        Ok(None) => (StatusCode::NOT_FOUND, Json(ApiResponse::<UserQuotaResponseDto>::not_found("UserQuota", &id))),
356        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::<UserQuotaResponseDto>::error(e.to_string()))),
357    }
358}
359
360/// Create routes for state transitions.
361pub fn create_user_quota_transition_routes(service: Arc<UserQuotaService>) -> Router {
362    use axum::routing::post;
363
364    Router::new()
365        .route("/user_quotas/:id/transitions/exceed", post(exceed_transition))
366        .route("/user_quotas/:id/transitions/restore", post(restore_transition))
367        .with_state(service)
368}
369