Skip to main content

allsource_core/infrastructure/web/
tenant_api.rs

1use crate::{
2    domain::{
3        entities::TenantQuotas,
4        value_objects::TenantId,
5    },
6    infrastructure::security::middleware::{Admin, Authenticated},
7};
8use axum::{Json, extract::State, http::StatusCode};
9use serde::{Deserialize, Serialize};
10
11// AppState is defined in api_v1.rs
12use crate::infrastructure::web::api_v1::AppState;
13
14// ============================================================================
15// Request/Response Types
16// ============================================================================
17
18#[derive(Debug, Deserialize)]
19pub struct CreateTenantRequest {
20    pub id: String,
21    pub name: String,
22    pub description: Option<String>,
23    pub quota_preset: Option<String>, // "free", "professional", "unlimited"
24    pub quotas: Option<TenantQuotas>,
25    #[serde(default)]
26    pub is_demo: bool,
27}
28
29#[derive(Debug, Serialize)]
30pub struct TenantResponse {
31    pub id: String,
32    pub name: String,
33    pub description: Option<String>,
34    pub quotas: TenantQuotas,
35    pub created_at: chrono::DateTime<chrono::Utc>,
36    pub updated_at: chrono::DateTime<chrono::Utc>,
37    pub active: bool,
38    pub is_demo: bool,
39}
40
41impl TenantResponse {
42    fn from_domain(tenant: &crate::domain::entities::Tenant) -> Self {
43        Self {
44            id: tenant.id().as_str().to_string(),
45            name: tenant.name().to_string(),
46            description: tenant.description().map(|s| s.to_string()),
47            quotas: tenant.quotas().clone(),
48            created_at: tenant.created_at(),
49            updated_at: tenant.updated_at(),
50            active: tenant.is_active(),
51            is_demo: tenant.is_demo(),
52        }
53    }
54}
55
56#[derive(Debug, Deserialize)]
57pub struct UpdateTenantRequest {
58    pub name: Option<String>,
59    pub description: Option<String>,
60    pub is_demo: Option<bool>,
61    pub quotas: Option<TenantQuotas>,
62}
63
64#[derive(Debug, Deserialize)]
65pub struct UpdateQuotasRequest {
66    pub quotas: TenantQuotas,
67}
68
69// ============================================================================
70// Handlers
71// ============================================================================
72
73/// Create tenant (admin only)
74/// POST /api/v1/tenants
75pub async fn create_tenant_handler(
76    State(state): State<AppState>,
77    Admin(_): Admin,
78    Json(req): Json<CreateTenantRequest>,
79) -> Result<(StatusCode, Json<TenantResponse>), (StatusCode, String)> {
80    // Determine quotas
81    let quotas = if let Some(quotas) = req.quotas {
82        quotas
83    } else if let Some(preset) = req.quota_preset {
84        match preset.as_str() {
85            "free" => TenantQuotas::free_tier(),
86            "professional" => TenantQuotas::professional(),
87            "unlimited" => TenantQuotas::unlimited(),
88            _ => TenantQuotas::default(),
89        }
90    } else {
91        TenantQuotas::default()
92    };
93
94    let tenant_id = TenantId::new(req.id)
95        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
96
97    let mut tenant = state
98        .tenant_repo
99        .create(tenant_id, req.name, quotas)
100        .await
101        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
102
103    // Apply optional fields that aren't part of create()
104    if let Some(desc) = req.description {
105        tenant.update_description(Some(desc));
106    }
107    if req.is_demo {
108        tenant.set_is_demo(true);
109    }
110
111    // Persist the updated fields
112    state
113        .tenant_repo
114        .save(&tenant)
115        .await
116        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
117
118    Ok((StatusCode::CREATED, Json(TenantResponse::from_domain(&tenant))))
119}
120
121/// Get tenant
122/// GET /api/v1/tenants/:id
123pub async fn get_tenant_handler(
124    State(state): State<AppState>,
125    Authenticated(auth_ctx): Authenticated,
126    axum::extract::Path(tenant_id): axum::extract::Path<String>,
127) -> Result<Json<TenantResponse>, (StatusCode, String)> {
128    // Users can only view their own tenant, admins can view any
129    if tenant_id != auth_ctx.tenant_id() {
130        auth_ctx
131            .require_permission(crate::infrastructure::security::auth::Permission::Admin)
132            .map_err(|_| {
133                (
134                    StatusCode::FORBIDDEN,
135                    "Can only view own tenant".to_string(),
136                )
137            })?;
138    }
139
140    let tid = TenantId::new(tenant_id.clone())
141        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
142
143    let tenant = state
144        .tenant_repo
145        .find_by_id(&tid)
146        .await
147        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
148        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Tenant not found: {}", tenant_id)))?;
149
150    Ok(Json(TenantResponse::from_domain(&tenant)))
151}
152
153/// List all tenants (admin only)
154/// GET /api/v1/tenants
155pub async fn list_tenants_handler(
156    State(state): State<AppState>,
157    Admin(_): Admin,
158) -> Result<Json<Vec<TenantResponse>>, (StatusCode, String)> {
159    let tenants = state
160        .tenant_repo
161        .find_all(10_000, 0)
162        .await
163        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
164    Ok(Json(tenants.iter().map(TenantResponse::from_domain).collect()))
165}
166
167/// Get tenant statistics
168/// GET /api/v1/tenants/:id/stats
169pub async fn get_tenant_stats_handler(
170    State(state): State<AppState>,
171    Authenticated(auth_ctx): Authenticated,
172    axum::extract::Path(tenant_id): axum::extract::Path<String>,
173) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
174    // Users can only view their own tenant stats
175    if tenant_id != auth_ctx.tenant_id() {
176        auth_ctx
177            .require_permission(crate::infrastructure::security::auth::Permission::Admin)
178            .map_err(|_| {
179                (
180                    StatusCode::FORBIDDEN,
181                    "Can only view own tenant stats".to_string(),
182                )
183            })?;
184    }
185
186    let tid = TenantId::new(tenant_id.clone())
187        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
188
189    let tenant = state
190        .tenant_repo
191        .find_by_id(&tid)
192        .await
193        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
194        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Tenant not found: {}", tenant_id)))?;
195
196    let stats = build_tenant_stats(&tenant);
197    Ok(Json(stats))
198}
199
200/// Update tenant quotas (admin only)
201/// PUT /api/v1/tenants/:id/quotas
202pub async fn update_quotas_handler(
203    State(state): State<AppState>,
204    Admin(_): Admin,
205    axum::extract::Path(tenant_id): axum::extract::Path<String>,
206    Json(req): Json<UpdateQuotasRequest>,
207) -> Result<StatusCode, (StatusCode, String)> {
208    let tid = TenantId::new(tenant_id.clone())
209        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
210
211    let updated = state
212        .tenant_repo
213        .update_quotas(&tid, req.quotas)
214        .await
215        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
216
217    if !updated {
218        return Err((StatusCode::NOT_FOUND, format!("Tenant not found: {}", tenant_id)));
219    }
220
221    Ok(StatusCode::NO_CONTENT)
222}
223
224/// Deactivate tenant (admin only)
225/// POST /api/v1/tenants/:id/deactivate
226pub async fn deactivate_tenant_handler(
227    State(state): State<AppState>,
228    Admin(_): Admin,
229    axum::extract::Path(tenant_id): axum::extract::Path<String>,
230) -> Result<StatusCode, (StatusCode, String)> {
231    let tid = TenantId::new(tenant_id.clone())
232        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
233
234    let deactivated = state
235        .tenant_repo
236        .deactivate(&tid)
237        .await
238        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
239
240    if !deactivated {
241        return Err((StatusCode::BAD_REQUEST, format!("Tenant not found: {}", tenant_id)));
242    }
243
244    Ok(StatusCode::NO_CONTENT)
245}
246
247/// Activate tenant (admin only)
248/// POST /api/v1/tenants/:id/activate
249pub async fn activate_tenant_handler(
250    State(state): State<AppState>,
251    Admin(_): Admin,
252    axum::extract::Path(tenant_id): axum::extract::Path<String>,
253) -> Result<StatusCode, (StatusCode, String)> {
254    let tid = TenantId::new(tenant_id.clone())
255        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
256
257    let activated = state
258        .tenant_repo
259        .activate(&tid)
260        .await
261        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
262
263    if !activated {
264        return Err((StatusCode::NOT_FOUND, format!("Tenant not found: {}", tenant_id)));
265    }
266
267    Ok(StatusCode::NO_CONTENT)
268}
269
270/// Update tenant (admin only)
271/// PUT /api/v1/tenants/:id
272pub async fn update_tenant_handler(
273    State(state): State<AppState>,
274    Admin(_): Admin,
275    axum::extract::Path(tenant_id): axum::extract::Path<String>,
276    Json(req): Json<UpdateTenantRequest>,
277) -> Result<Json<TenantResponse>, (StatusCode, String)> {
278    let tid = TenantId::new(tenant_id.clone())
279        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
280
281    let mut tenant = state
282        .tenant_repo
283        .find_by_id(&tid)
284        .await
285        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
286        .ok_or_else(|| (StatusCode::NOT_FOUND, format!("Tenant not found: {}", tenant_id)))?;
287
288    if let Some(name) = req.name {
289        tenant.update_name(name)
290            .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
291    }
292    if let Some(desc) = req.description {
293        tenant.update_description(Some(desc));
294    }
295    if let Some(is_demo) = req.is_demo {
296        tenant.set_is_demo(is_demo);
297    }
298    if let Some(quotas) = req.quotas {
299        tenant.update_quotas(quotas);
300    }
301
302    state
303        .tenant_repo
304        .save(&tenant)
305        .await
306        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
307
308    Ok(Json(TenantResponse::from_domain(&tenant)))
309}
310
311/// Delete tenant (admin only)
312/// DELETE /api/v1/tenants/:id
313pub async fn delete_tenant_handler(
314    State(state): State<AppState>,
315    Admin(_): Admin,
316    axum::extract::Path(tenant_id): axum::extract::Path<String>,
317) -> Result<StatusCode, (StatusCode, String)> {
318    let tid = TenantId::new(tenant_id.clone())
319        .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
320
321    let deleted = state
322        .tenant_repo
323        .delete(&tid)
324        .await
325        .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
326
327    if !deleted {
328        return Err((StatusCode::BAD_REQUEST, format!("Tenant not found: {}", tenant_id)));
329    }
330
331    Ok(StatusCode::NO_CONTENT)
332}
333
334// ============================================================================
335// Helpers
336// ============================================================================
337
338/// Build tenant statistics JSON (presentation concern).
339fn build_tenant_stats(tenant: &crate::domain::entities::Tenant) -> serde_json::Value {
340    let quotas = tenant.quotas();
341    let usage = tenant.usage();
342
343    let events_pct = if quotas.max_events_per_day() > 0 {
344        (usage.events_today() as f64 / quotas.max_events_per_day() as f64) * 100.0
345    } else {
346        0.0
347    };
348
349    let storage_pct = if quotas.max_storage_bytes() > 0 {
350        (usage.storage_bytes() as f64 / quotas.max_storage_bytes() as f64) * 100.0
351    } else {
352        0.0
353    };
354
355    let queries_pct = if quotas.max_queries_per_hour() > 0 {
356        (usage.queries_this_hour() as f64 / quotas.max_queries_per_hour() as f64) * 100.0
357    } else {
358        0.0
359    };
360
361    serde_json::json!({
362        "tenant_id": tenant.id().as_str(),
363        "name": tenant.name(),
364        "active": tenant.is_active(),
365        "is_demo": tenant.is_demo(),
366        "usage": tenant.usage(),
367        "quotas": tenant.quotas(),
368        "utilization": {
369            "events_today": {
370                "used": usage.events_today(),
371                "limit": quotas.max_events_per_day(),
372                "percentage": events_pct.min(100.0)
373            },
374            "storage": {
375                "used_bytes": usage.storage_bytes(),
376                "limit_bytes": quotas.max_storage_bytes(),
377                "percentage": storage_pct.min(100.0)
378            },
379            "queries_this_hour": {
380                "used": usage.queries_this_hour(),
381                "limit": quotas.max_queries_per_hour(),
382                "percentage": queries_pct.min(100.0)
383            }
384        },
385        "created_at": tenant.created_at(),
386        "updated_at": tenant.updated_at()
387    })
388}