Skip to main content

allsource_core/infrastructure/web/
tenant_api.rs

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