Skip to main content

koprogo_api/infrastructure/web/handlers/
organization_handlers.rs

1use crate::application::dto::{PageRequest, PageResponse};
2use crate::domain::entities::Organization;
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9#[derive(Serialize)]
10pub struct OrganizationResponse {
11    pub id: String,
12    pub name: String,
13    pub slug: String,
14    pub contact_email: String,
15    pub contact_phone: Option<String>,
16    pub subscription_plan: String,
17    pub max_buildings: i32,
18    pub max_users: i32,
19    pub is_active: bool,
20    pub created_at: DateTime<Utc>,
21}
22
23fn to_response(org: Organization) -> OrganizationResponse {
24    OrganizationResponse {
25        id: org.id.to_string(),
26        name: org.name,
27        slug: org.slug,
28        contact_email: org.contact_email,
29        contact_phone: org.contact_phone,
30        subscription_plan: org.subscription_plan.to_string(),
31        max_buildings: org.max_buildings,
32        max_users: org.max_users,
33        is_active: org.is_active,
34        created_at: org.created_at,
35    }
36}
37
38#[derive(Deserialize)]
39pub struct CreateOrganizationRequest {
40    pub name: String,
41    pub slug: String,
42    pub contact_email: String,
43    pub contact_phone: Option<String>,
44    pub subscription_plan: String,
45}
46
47#[derive(Deserialize)]
48pub struct UpdateOrganizationRequest {
49    pub name: String,
50    pub slug: String,
51    pub contact_email: String,
52    pub contact_phone: Option<String>,
53    pub subscription_plan: String,
54}
55
56/// Recherche libre sur le nom ou le slug.
57#[derive(serde::Deserialize)]
58pub struct RechercheOrganisation {
59    pub q: Option<String>,
60}
61
62/// GET /api/v1/organizations
63/// Une PAGE d'organisations, filtrable (SuperAdmin only).
64///
65/// Cette route rendait la table ENTIÈRE en ignorant `per_page` : 2743 lignes
66/// sur la recette au 2026-09-17, d'où 8,2 s d'écran blanc sur `/admin/acps`
67/// contre 1,9 s sur `/admin/users` (#943).
68///
69/// Elle rend désormais `PageResponse`, comme les huit autres routes
70/// paginées du dépôt. La clé `data` ne bouge pas — les appelants qui la
71/// lisent continuent de fonctionner — et `pagination` s'y ajoute, avec le
72/// total. C'est ce total qui permet à l'appelant de savoir qu'il ne voit
73/// qu'un fragment, au lieu de le deviner.
74#[get("/organizations")]
75pub async fn list_organizations(
76    state: web::Data<AppState>,
77    user: AuthenticatedUser,
78    page_request: web::Query<PageRequest>,
79    recherche: web::Query<RechercheOrganisation>,
80) -> impl Responder {
81    if !user.is_superadmin() {
82        return HttpResponse::Forbidden().json(serde_json::json!({
83            "error": "Only SuperAdmin can access all organizations"
84        }));
85    }
86
87    let per_page = page_request.per_page.max(1);
88    let page = page_request.page.max(1);
89    let offset = (page - 1) * per_page;
90
91    match state
92        .organization_use_cases
93        .list_page(recherche.q.clone(), per_page, offset)
94        .await
95    {
96        Ok((orgs, total)) => HttpResponse::Ok().json(PageResponse::new(
97            orgs.into_iter().map(to_response).collect::<Vec<_>>(),
98            page,
99            per_page,
100            total,
101        )),
102        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
103            "error": format!("Failed to fetch organizations: {}", e)
104        })),
105    }
106}
107
108/// POST /api/v1/organizations
109/// Create organization (SuperAdmin only)
110#[post("/organizations")]
111pub async fn create_organization(
112    state: web::Data<AppState>,
113    user: AuthenticatedUser,
114    req: web::Json<CreateOrganizationRequest>,
115) -> impl Responder {
116    if !user.is_superadmin() {
117        return HttpResponse::Forbidden().json(serde_json::json!({
118            "error": "Only SuperAdmin can create organizations"
119        }));
120    }
121
122    match state
123        .organization_use_cases
124        .create(
125            req.name.clone(),
126            req.slug.clone(),
127            req.contact_email.clone(),
128            req.contact_phone.clone(),
129            req.subscription_plan.clone(),
130        )
131        .await
132    {
133        Ok(org) => {
134            // Sans ce seeding, toute création d'owner-contribution/expense
135            // échoue avec une violation de FK sur account_code tant que le
136            // job de démarrage (seed_belgian_pcmn_for_all_organizations)
137            // n'a pas tourné pour cette org — jamais le cas pour une org
138            // créée après le boot du serveur.
139            if let Err(e) = state.account_use_cases.seed_belgian_pcmn(org.id).await {
140                log::error!(
141                    "Failed to seed Belgian PCMN accounts for new organization {}: {}",
142                    org.id,
143                    e
144                );
145            }
146            HttpResponse::Created().json(to_response(org))
147        }
148        Err(e) if e == "invalid_plan" => HttpResponse::BadRequest().json(serde_json::json!({
149            "error": "Invalid subscription plan"
150        })),
151        Err(e) if e.starts_with("validation_error:") => {
152            HttpResponse::BadRequest().json(serde_json::json!({
153                "error": e.trim_start_matches("validation_error:")
154            }))
155        }
156        Err(e) if e.contains("unique") || e.contains("duplicate") => HttpResponse::BadRequest()
157            .json(serde_json::json!({
158                "error": "Slug already exists"
159            })),
160        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
161            "error": format!("Failed to create organization: {}", e)
162        })),
163    }
164}
165
166/// PUT /api/v1/organizations/{id}
167/// Update organization (SuperAdmin only)
168#[put("/organizations/{id}")]
169pub async fn update_organization(
170    state: web::Data<AppState>,
171    user: AuthenticatedUser,
172    path: web::Path<Uuid>,
173    req: web::Json<UpdateOrganizationRequest>,
174) -> impl Responder {
175    if !user.is_superadmin() {
176        return HttpResponse::Forbidden().json(serde_json::json!({
177            "error": "Only SuperAdmin can update organizations"
178        }));
179    }
180
181    let org_id = path.into_inner();
182
183    match state
184        .organization_use_cases
185        .update(
186            org_id,
187            req.name.clone(),
188            req.slug.clone(),
189            req.contact_email.clone(),
190            req.contact_phone.clone(),
191            req.subscription_plan.clone(),
192        )
193        .await
194    {
195        Ok(org) => HttpResponse::Ok().json(to_response(org)),
196        Err(e) if e == "not_found" => HttpResponse::NotFound().json(serde_json::json!({
197            "error": "Organization not found"
198        })),
199        Err(e) if e == "invalid_plan" => HttpResponse::BadRequest().json(serde_json::json!({
200            "error": "Invalid subscription plan"
201        })),
202        Err(e) if e.starts_with("validation_error:") => {
203            HttpResponse::BadRequest().json(serde_json::json!({
204                "error": e.trim_start_matches("validation_error:")
205            }))
206        }
207        Err(e) if e.contains("unique") || e.contains("duplicate") => HttpResponse::BadRequest()
208            .json(serde_json::json!({
209                "error": "Slug already exists"
210            })),
211        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
212            "error": format!("Failed to update organization: {}", e)
213        })),
214    }
215}
216
217/// PUT /api/v1/organizations/{id}/activate
218/// Activate organization (SuperAdmin only)
219#[put("/organizations/{id}/activate")]
220pub async fn activate_organization(
221    state: web::Data<AppState>,
222    user: AuthenticatedUser,
223    path: web::Path<Uuid>,
224) -> impl Responder {
225    if !user.is_superadmin() {
226        return HttpResponse::Forbidden().json(serde_json::json!({
227            "error": "Only SuperAdmin can activate organizations"
228        }));
229    }
230
231    let org_id = path.into_inner();
232
233    match state.organization_use_cases.activate(org_id).await {
234        Ok(org) => HttpResponse::Ok().json(to_response(org)),
235        Err(e) if e == "not_found" => HttpResponse::NotFound().json(serde_json::json!({
236            "error": "Organization not found"
237        })),
238        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
239            "error": format!("Failed to activate organization: {}", e)
240        })),
241    }
242}
243
244/// PUT /api/v1/organizations/{id}/suspend
245/// Suspend organization (SuperAdmin only)
246#[put("/organizations/{id}/suspend")]
247pub async fn suspend_organization(
248    state: web::Data<AppState>,
249    user: AuthenticatedUser,
250    path: web::Path<Uuid>,
251) -> impl Responder {
252    if !user.is_superadmin() {
253        return HttpResponse::Forbidden().json(serde_json::json!({
254            "error": "Only SuperAdmin can suspend organizations"
255        }));
256    }
257
258    let org_id = path.into_inner();
259
260    match state.organization_use_cases.suspend(org_id).await {
261        Ok(org) => HttpResponse::Ok().json(to_response(org)),
262        Err(e) if e == "not_found" => HttpResponse::NotFound().json(serde_json::json!({
263            "error": "Organization not found"
264        })),
265        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
266            "error": format!("Failed to suspend organization: {}", e)
267        })),
268    }
269}
270
271/// DELETE /api/v1/organizations/{id}
272/// Delete organization (SuperAdmin only)
273#[delete("/organizations/{id}")]
274pub async fn delete_organization(
275    state: web::Data<AppState>,
276    user: AuthenticatedUser,
277    path: web::Path<Uuid>,
278) -> impl Responder {
279    if !user.is_superadmin() {
280        return HttpResponse::Forbidden().json(serde_json::json!({
281            "error": "Only SuperAdmin can delete organizations"
282        }));
283    }
284
285    let org_id = path.into_inner();
286
287    match state.organization_use_cases.delete(org_id).await {
288        Ok(true) => HttpResponse::Ok().json(serde_json::json!({
289            "message": "Organization deleted successfully"
290        })),
291        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
292            "error": "Organization not found"
293        })),
294        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
295            "error": format!("Failed to delete organization: {}", e)
296        })),
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_to_response_maps_all_fields() {
306        let org = Organization {
307            id: Uuid::new_v4(),
308            name: "Test Org".to_string(),
309            slug: "test-org".to_string(),
310            contact_email: "admin@test.com".to_string(),
311            contact_phone: Some("+32123456789".to_string()),
312            subscription_plan: crate::domain::entities::SubscriptionPlan::Professional,
313            max_buildings: 20,
314            max_users: 50,
315            is_active: true,
316            created_at: Utc::now(),
317            updated_at: Utc::now(),
318        };
319        let resp = to_response(org);
320        assert_eq!(resp.name, "Test Org");
321        assert_eq!(resp.slug, "test-org");
322        assert_eq!(resp.subscription_plan, "professional");
323        assert_eq!(resp.max_buildings, 20);
324    }
325
326    #[test]
327    fn test_to_response_inactive_org() {
328        let org = Organization {
329            id: Uuid::new_v4(),
330            name: "Inactive".to_string(),
331            slug: "inactive".to_string(),
332            contact_email: "x@x.com".to_string(),
333            contact_phone: None,
334            subscription_plan: crate::domain::entities::SubscriptionPlan::Free,
335            max_buildings: 1,
336            max_users: 3,
337            is_active: false,
338            created_at: Utc::now(),
339            updated_at: Utc::now(),
340        };
341        let resp = to_response(org);
342        assert!(!resp.is_active);
343        assert!(resp.contact_phone.is_none());
344    }
345}