koprogo_api/infrastructure/web/handlers/
organization_handlers.rs

1use crate::infrastructure::web::{AppState, AuthenticatedUser};
2use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Serialize)]
8pub struct OrganizationResponse {
9    pub id: String,
10    pub name: String,
11    pub slug: String,
12    pub contact_email: String,
13    pub contact_phone: Option<String>,
14    pub subscription_plan: String,
15    pub max_buildings: i32,
16    pub max_users: i32,
17    pub is_active: bool,
18    pub created_at: DateTime<Utc>,
19}
20
21/// List all organizations (SuperAdmin only)
22#[get("/organizations")]
23pub async fn list_organizations(
24    state: web::Data<AppState>,
25    user: AuthenticatedUser,
26) -> impl Responder {
27    // Only SuperAdmin can access all organizations
28    if user.role != "superadmin" {
29        return HttpResponse::Forbidden().json(serde_json::json!({
30            "error": "Only SuperAdmin can access all organizations"
31        }));
32    }
33
34    let result = sqlx::query!(
35        r#"
36        SELECT id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at
37        FROM organizations
38        ORDER BY created_at DESC
39        "#
40    )
41    .fetch_all(&state.pool)
42    .await;
43
44    match result {
45        Ok(rows) => {
46            let organizations: Vec<OrganizationResponse> = rows
47                .into_iter()
48                .map(|row| OrganizationResponse {
49                    id: row.id.to_string(),
50                    name: row.name,
51                    slug: row.slug,
52                    contact_email: row.contact_email,
53                    contact_phone: row.contact_phone,
54                    subscription_plan: row.subscription_plan,
55                    max_buildings: row.max_buildings,
56                    max_users: row.max_users,
57                    is_active: row.is_active,
58                    created_at: row.created_at,
59                })
60                .collect();
61
62            HttpResponse::Ok().json(serde_json::json!({
63                "data": organizations
64            }))
65        }
66        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
67            "error": format!("Failed to fetch organizations: {}", e)
68        })),
69    }
70}
71
72#[derive(Deserialize)]
73pub struct CreateOrganizationRequest {
74    pub name: String,
75    pub slug: String,
76    pub contact_email: String,
77    pub contact_phone: Option<String>,
78    pub subscription_plan: String,
79}
80
81#[derive(Deserialize)]
82pub struct UpdateOrganizationRequest {
83    pub name: String,
84    pub slug: String,
85    pub contact_email: String,
86    pub contact_phone: Option<String>,
87    pub subscription_plan: String,
88}
89
90/// Create organization (SuperAdmin only)
91#[post("/organizations")]
92pub async fn create_organization(
93    state: web::Data<AppState>,
94    user: AuthenticatedUser,
95    req: web::Json<CreateOrganizationRequest>,
96) -> impl Responder {
97    // Only SuperAdmin can create organizations
98    if user.role != "superadmin" {
99        return HttpResponse::Forbidden().json(serde_json::json!({
100            "error": "Only SuperAdmin can create organizations"
101        }));
102    }
103
104    // Validate subscription plan
105    let valid_plans = ["free", "starter", "professional", "enterprise"];
106    if !valid_plans.contains(&req.subscription_plan.to_lowercase().as_str()) {
107        return HttpResponse::BadRequest().json(serde_json::json!({
108            "error": "Invalid subscription plan"
109        }));
110    }
111
112    // Validate email format
113    if !req.contact_email.contains('@') {
114        return HttpResponse::BadRequest().json(serde_json::json!({
115            "error": "Invalid email format"
116        }));
117    }
118
119    // Validate name and slug lengths
120    if req.name.trim().len() < 2 || req.slug.trim().len() < 2 {
121        return HttpResponse::BadRequest().json(serde_json::json!({
122            "error": "Name and slug must be at least 2 characters"
123        }));
124    }
125
126    // Validate slug format (lowercase alphanumeric and hyphens only)
127    if !req
128        .slug
129        .chars()
130        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
131    {
132        return HttpResponse::BadRequest().json(serde_json::json!({
133            "error": "Slug must contain only lowercase letters, numbers, and hyphens"
134        }));
135    }
136
137    // Determine limits based on plan
138    let (max_buildings, max_users) = match req.subscription_plan.to_lowercase().as_str() {
139        "free" => (1, 3),
140        "starter" => (5, 10),
141        "professional" => (20, 50),
142        "enterprise" => (999, 999),
143        _ => (1, 3), // Default to free
144    };
145
146    // Generate UUID
147    let org_id = Uuid::new_v4();
148
149    let result = sqlx::query!(
150        r#"
151        INSERT INTO organizations (id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at, updated_at)
152        VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, NOW(), NOW())
153        RETURNING id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at
154        "#,
155        org_id,
156        req.name.trim(),
157        req.slug.trim().to_lowercase(),
158        req.contact_email.trim().to_lowercase(),
159        req.contact_phone.as_ref().map(|s| s.trim()),
160        req.subscription_plan.to_lowercase(),
161        max_buildings,
162        max_users
163    )
164    .fetch_one(&state.pool)
165    .await;
166
167    match result {
168        Ok(row) => HttpResponse::Created().json(OrganizationResponse {
169            id: row.id.to_string(),
170            name: row.name,
171            slug: row.slug,
172            contact_email: row.contact_email,
173            contact_phone: row.contact_phone,
174            subscription_plan: row.subscription_plan,
175            max_buildings: row.max_buildings,
176            max_users: row.max_users,
177            is_active: row.is_active,
178            created_at: row.created_at,
179        }),
180        Err(sqlx::Error::Database(db_err)) => {
181            if db_err.is_unique_violation() {
182                HttpResponse::BadRequest().json(serde_json::json!({
183                    "error": "Slug already exists"
184                }))
185            } else {
186                HttpResponse::InternalServerError().json(serde_json::json!({
187                    "error": format!("Failed to create organization: {}", db_err)
188                }))
189            }
190        }
191        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
192            "error": format!("Failed to create organization: {}", e)
193        })),
194    }
195}
196
197/// Update organization (SuperAdmin only)
198#[put("/organizations/{id}")]
199pub async fn update_organization(
200    state: web::Data<AppState>,
201    user: AuthenticatedUser,
202    path: web::Path<Uuid>,
203    req: web::Json<UpdateOrganizationRequest>,
204) -> impl Responder {
205    // Only SuperAdmin can update organizations
206    if user.role != "superadmin" {
207        return HttpResponse::Forbidden().json(serde_json::json!({
208            "error": "Only SuperAdmin can update organizations"
209        }));
210    }
211
212    let org_id = path.into_inner();
213
214    // Validate subscription plan
215    let valid_plans = ["free", "starter", "professional", "enterprise"];
216    if !valid_plans.contains(&req.subscription_plan.to_lowercase().as_str()) {
217        return HttpResponse::BadRequest().json(serde_json::json!({
218            "error": "Invalid subscription plan"
219        }));
220    }
221
222    // Validate email format
223    if !req.contact_email.contains('@') {
224        return HttpResponse::BadRequest().json(serde_json::json!({
225            "error": "Invalid email format"
226        }));
227    }
228
229    // Validate name and slug lengths
230    if req.name.trim().len() < 2 || req.slug.trim().len() < 2 {
231        return HttpResponse::BadRequest().json(serde_json::json!({
232            "error": "Name and slug must be at least 2 characters"
233        }));
234    }
235
236    // Validate slug format
237    if !req
238        .slug
239        .chars()
240        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
241    {
242        return HttpResponse::BadRequest().json(serde_json::json!({
243            "error": "Slug must contain only lowercase letters, numbers, and hyphens"
244        }));
245    }
246
247    // Determine limits based on plan
248    let (max_buildings, max_users) = match req.subscription_plan.to_lowercase().as_str() {
249        "free" => (1, 3),
250        "starter" => (5, 10),
251        "professional" => (20, 50),
252        "enterprise" => (999, 999),
253        _ => (1, 3),
254    };
255
256    let result = sqlx::query!(
257        r#"
258        UPDATE organizations
259        SET name = $1, slug = $2, contact_email = $3, contact_phone = $4, subscription_plan = $5, max_buildings = $6, max_users = $7, updated_at = NOW()
260        WHERE id = $8
261        RETURNING id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at
262        "#,
263        req.name.trim(),
264        req.slug.trim().to_lowercase(),
265        req.contact_email.trim().to_lowercase(),
266        req.contact_phone.as_ref().map(|s| s.trim()),
267        req.subscription_plan.to_lowercase(),
268        max_buildings,
269        max_users,
270        org_id
271    )
272    .fetch_one(&state.pool)
273    .await;
274
275    match result {
276        Ok(row) => HttpResponse::Ok().json(OrganizationResponse {
277            id: row.id.to_string(),
278            name: row.name,
279            slug: row.slug,
280            contact_email: row.contact_email,
281            contact_phone: row.contact_phone,
282            subscription_plan: row.subscription_plan,
283            max_buildings: row.max_buildings,
284            max_users: row.max_users,
285            is_active: row.is_active,
286            created_at: row.created_at,
287        }),
288        Err(sqlx::Error::RowNotFound) => HttpResponse::NotFound().json(serde_json::json!({
289            "error": "Organization not found"
290        })),
291        Err(sqlx::Error::Database(db_err)) => {
292            if db_err.is_unique_violation() {
293                HttpResponse::BadRequest().json(serde_json::json!({
294                    "error": "Slug already exists"
295                }))
296            } else {
297                HttpResponse::InternalServerError().json(serde_json::json!({
298                    "error": format!("Failed to update organization: {}", db_err)
299                }))
300            }
301        }
302        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
303            "error": format!("Failed to update organization: {}", e)
304        })),
305    }
306}
307
308/// Activate organization (SuperAdmin only)
309#[put("/organizations/{id}/activate")]
310pub async fn activate_organization(
311    state: web::Data<AppState>,
312    user: AuthenticatedUser,
313    path: web::Path<Uuid>,
314) -> impl Responder {
315    // Only SuperAdmin can activate organizations
316    if user.role != "superadmin" {
317        return HttpResponse::Forbidden().json(serde_json::json!({
318            "error": "Only SuperAdmin can activate organizations"
319        }));
320    }
321
322    let org_id = path.into_inner();
323
324    let result = sqlx::query!(
325        r#"
326        UPDATE organizations
327        SET is_active = true, updated_at = NOW()
328        WHERE id = $1
329        RETURNING id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at
330        "#,
331        org_id
332    )
333    .fetch_one(&state.pool)
334    .await;
335
336    match result {
337        Ok(row) => HttpResponse::Ok().json(OrganizationResponse {
338            id: row.id.to_string(),
339            name: row.name,
340            slug: row.slug,
341            contact_email: row.contact_email,
342            contact_phone: row.contact_phone,
343            subscription_plan: row.subscription_plan,
344            max_buildings: row.max_buildings,
345            max_users: row.max_users,
346            is_active: row.is_active,
347            created_at: row.created_at,
348        }),
349        Err(sqlx::Error::RowNotFound) => HttpResponse::NotFound().json(serde_json::json!({
350            "error": "Organization not found"
351        })),
352        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
353            "error": format!("Failed to activate organization: {}", e)
354        })),
355    }
356}
357
358/// Suspend organization (SuperAdmin only)
359#[put("/organizations/{id}/suspend")]
360pub async fn suspend_organization(
361    state: web::Data<AppState>,
362    user: AuthenticatedUser,
363    path: web::Path<Uuid>,
364) -> impl Responder {
365    // Only SuperAdmin can suspend organizations
366    if user.role != "superadmin" {
367        return HttpResponse::Forbidden().json(serde_json::json!({
368            "error": "Only SuperAdmin can suspend organizations"
369        }));
370    }
371
372    let org_id = path.into_inner();
373
374    let result = sqlx::query!(
375        r#"
376        UPDATE organizations
377        SET is_active = false, updated_at = NOW()
378        WHERE id = $1
379        RETURNING id, name, slug, contact_email, contact_phone, subscription_plan, max_buildings, max_users, is_active, created_at
380        "#,
381        org_id
382    )
383    .fetch_one(&state.pool)
384    .await;
385
386    match result {
387        Ok(row) => HttpResponse::Ok().json(OrganizationResponse {
388            id: row.id.to_string(),
389            name: row.name,
390            slug: row.slug,
391            contact_email: row.contact_email,
392            contact_phone: row.contact_phone,
393            subscription_plan: row.subscription_plan,
394            max_buildings: row.max_buildings,
395            max_users: row.max_users,
396            is_active: row.is_active,
397            created_at: row.created_at,
398        }),
399        Err(sqlx::Error::RowNotFound) => HttpResponse::NotFound().json(serde_json::json!({
400            "error": "Organization not found"
401        })),
402        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
403            "error": format!("Failed to suspend organization: {}", e)
404        })),
405    }
406}
407
408/// Delete organization (SuperAdmin only)
409#[delete("/organizations/{id}")]
410pub async fn delete_organization(
411    state: web::Data<AppState>,
412    user: AuthenticatedUser,
413    path: web::Path<Uuid>,
414) -> impl Responder {
415    // Only SuperAdmin can delete organizations
416    if user.role != "superadmin" {
417        return HttpResponse::Forbidden().json(serde_json::json!({
418            "error": "Only SuperAdmin can delete organizations"
419        }));
420    }
421
422    let org_id = path.into_inner();
423
424    let result = sqlx::query!(
425        r#"
426        DELETE FROM organizations
427        WHERE id = $1
428        "#,
429        org_id
430    )
431    .execute(&state.pool)
432    .await;
433
434    match result {
435        Ok(result) => {
436            if result.rows_affected() == 0 {
437                HttpResponse::NotFound().json(serde_json::json!({
438                    "error": "Organization not found"
439                }))
440            } else {
441                HttpResponse::Ok().json(serde_json::json!({
442                    "message": "Organization deleted successfully"
443                }))
444            }
445        }
446        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
447            "error": format!("Failed to delete organization: {}", e)
448        })),
449    }
450}