koprogo_api/infrastructure/web/handlers/
organization_handlers.rs1use 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#[derive(serde::Deserialize)]
58pub struct RechercheOrganisation {
59 pub q: Option<String>,
60}
61
62#[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("/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 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("/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("/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("/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("/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}