koprogo_api/infrastructure/web/handlers/
unit_handlers.rs

1use crate::application::dto::{CreateUnitDto, PageRequest, PageResponse, UpdateUnitDto};
2use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
3use crate::infrastructure::web::{AppState, AuthenticatedUser};
4use actix_web::{delete, get, post, put, web, HttpResponse, Responder};
5use uuid::Uuid;
6use validator::Validate;
7
8#[post("/units")]
9pub async fn create_unit(
10    state: web::Data<AppState>,
11    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
12    dto: web::Json<CreateUnitDto>,
13) -> impl Responder {
14    // Only SuperAdmin can create units (structural data)
15    if user.role != "superadmin" {
16        return HttpResponse::Forbidden().json(serde_json::json!({
17            "error": "Only SuperAdmin can create units (structural data cannot be modified after creation)"
18        }));
19    }
20
21    // SuperAdmin must specify organization_id and building_id in the request body
22    // Validate that both are provided
23    if dto.organization_id.is_empty() {
24        return HttpResponse::BadRequest().json(serde_json::json!({
25            "error": "SuperAdmin must specify organization_id"
26        }));
27    }
28
29    if dto.building_id.is_empty() {
30        return HttpResponse::BadRequest().json(serde_json::json!({
31            "error": "SuperAdmin must specify building_id"
32        }));
33    }
34
35    // Parse organization_id for audit logging
36    let organization_id = match Uuid::parse_str(&dto.organization_id) {
37        Ok(org_id) => org_id,
38        Err(_) => {
39            return HttpResponse::BadRequest().json(serde_json::json!({
40                "error": "Invalid organization_id format"
41            }))
42        }
43    };
44
45    if let Err(errors) = dto.validate() {
46        return HttpResponse::BadRequest().json(serde_json::json!({
47            "error": "Validation failed",
48            "details": errors.to_string()
49        }));
50    }
51
52    match state.unit_use_cases.create_unit(dto.into_inner()).await {
53        Ok(unit) => {
54            // Audit log: successful unit creation
55            AuditLogEntry::new(
56                AuditEventType::UnitCreated,
57                Some(user.user_id),
58                Some(organization_id),
59            )
60            .with_resource("Unit", Uuid::parse_str(&unit.id).unwrap())
61            .log();
62
63            HttpResponse::Created().json(unit)
64        }
65        Err(err) => {
66            // Audit log: failed unit creation
67            AuditLogEntry::new(
68                AuditEventType::UnitCreated,
69                Some(user.user_id),
70                Some(organization_id),
71            )
72            .with_error(err.clone())
73            .log();
74
75            HttpResponse::BadRequest().json(serde_json::json!({
76                "error": err
77            }))
78        }
79    }
80}
81
82#[get("/units/{id}")]
83pub async fn get_unit(
84    state: web::Data<AppState>,
85    user: AuthenticatedUser,
86    id: web::Path<Uuid>,
87) -> impl Responder {
88    match state.unit_use_cases.get_unit(*id).await {
89        Ok(Some(unit)) => {
90            // Multi-tenant isolation: verify building belongs to user's organization
91            if let Ok(building_id) = Uuid::parse_str(&unit.building_id) {
92                if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
93                {
94                    if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
95                        if let Err(e) = user.verify_org_access(building_org) {
96                            return HttpResponse::Forbidden()
97                                .json(serde_json::json!({ "error": e }));
98                        }
99                    }
100                }
101            }
102            HttpResponse::Ok().json(unit)
103        }
104        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
105            "error": "Unit not found"
106        })),
107        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
108            "error": err
109        })),
110    }
111}
112
113#[get("/units")]
114pub async fn list_units(
115    state: web::Data<AppState>,
116    user: AuthenticatedUser,
117    page_request: web::Query<PageRequest>,
118) -> impl Responder {
119    let organization_id = user.organization_id;
120
121    match state
122        .unit_use_cases
123        .list_units_paginated(&page_request, organization_id)
124        .await
125    {
126        Ok((units, total)) => {
127            let response =
128                PageResponse::new(units, page_request.page, page_request.per_page, total);
129            HttpResponse::Ok().json(response)
130        }
131        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
132            "error": err
133        })),
134    }
135}
136
137#[get("/buildings/{building_id}/units")]
138pub async fn list_units_by_building(
139    state: web::Data<AppState>,
140    user: AuthenticatedUser,
141    building_id: web::Path<Uuid>,
142) -> impl Responder {
143    // Multi-tenant isolation: verify building belongs to user's organization
144    match state.building_use_cases.get_building(*building_id).await {
145        Ok(Some(building)) => {
146            if let Ok(building_org) = Uuid::parse_str(&building.organization_id) {
147                if let Err(e) = user.verify_org_access(building_org) {
148                    return HttpResponse::Forbidden().json(serde_json::json!({ "error": e }));
149                }
150            }
151        }
152        Ok(None) => {
153            return HttpResponse::NotFound().json(serde_json::json!({
154                "error": "Building not found"
155            }));
156        }
157        Err(err) => {
158            return HttpResponse::InternalServerError().json(serde_json::json!({
159                "error": err
160            }));
161        }
162    }
163
164    match state
165        .unit_use_cases
166        .list_units_by_building(*building_id)
167        .await
168    {
169        Ok(units) => HttpResponse::Ok().json(units),
170        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
171            "error": err
172        })),
173    }
174}
175
176#[put("/units/{id}")]
177pub async fn update_unit(
178    state: web::Data<AppState>,
179    user: AuthenticatedUser,
180    id: web::Path<Uuid>,
181    dto: web::Json<UpdateUnitDto>,
182) -> impl Responder {
183    // Only SuperAdmin can update units (structural data including quotités)
184    if user.role != "superadmin" {
185        return HttpResponse::Forbidden().json(serde_json::json!({
186            "error": "Only SuperAdmin can update units (structural data including quotités)"
187        }));
188    }
189
190    if let Err(errors) = dto.validate() {
191        return HttpResponse::BadRequest().json(serde_json::json!({
192            "error": "Validation failed",
193            "details": errors.to_string()
194        }));
195    }
196
197    // Verify the user owns the unit (via building organization check)
198    if user.role != "superadmin" {
199        match state.unit_use_cases.get_unit(*id).await {
200            Ok(Some(unit)) => {
201                // Get the building to check organization
202                let building_id = match Uuid::parse_str(&unit.building_id) {
203                    Ok(id) => id,
204                    Err(_) => {
205                        return HttpResponse::InternalServerError().json(serde_json::json!({
206                            "error": "Invalid building_id"
207                        }));
208                    }
209                };
210
211                match state.building_use_cases.get_building(building_id).await {
212                    Ok(Some(building)) => {
213                        let building_org_id = match Uuid::parse_str(&building.organization_id) {
214                            Ok(id) => id,
215                            Err(_) => {
216                                return HttpResponse::InternalServerError().json(
217                                    serde_json::json!({
218                                        "error": "Invalid building organization_id"
219                                    }),
220                                );
221                            }
222                        };
223
224                        let user_org_id = match user.require_organization() {
225                            Ok(id) => id,
226                            Err(e) => {
227                                return HttpResponse::Unauthorized().json(serde_json::json!({
228                                    "error": e.to_string()
229                                }));
230                            }
231                        };
232
233                        if building_org_id != user_org_id {
234                            return HttpResponse::Forbidden().json(serde_json::json!({
235                                "error": "You can only update units in your own organization"
236                            }));
237                        }
238                    }
239                    Ok(None) => {
240                        return HttpResponse::NotFound().json(serde_json::json!({
241                            "error": "Building not found"
242                        }));
243                    }
244                    Err(err) => {
245                        return HttpResponse::InternalServerError().json(serde_json::json!({
246                            "error": err
247                        }));
248                    }
249                }
250            }
251            Ok(None) => {
252                return HttpResponse::NotFound().json(serde_json::json!({
253                    "error": "Unit not found"
254                }));
255            }
256            Err(err) => {
257                return HttpResponse::InternalServerError().json(serde_json::json!({
258                    "error": err
259                }));
260            }
261        }
262    }
263
264    match state
265        .unit_use_cases
266        .update_unit(*id, dto.into_inner())
267        .await
268    {
269        Ok(unit) => {
270            // Audit log: successful unit update
271            AuditLogEntry::new(
272                AuditEventType::UnitUpdated,
273                Some(user.user_id),
274                user.organization_id,
275            )
276            .with_resource("Unit", *id)
277            .log();
278
279            HttpResponse::Ok().json(unit)
280        }
281        Err(err) => {
282            // Audit log: failed unit update
283            AuditLogEntry::new(
284                AuditEventType::UnitUpdated,
285                Some(user.user_id),
286                user.organization_id,
287            )
288            .with_resource("Unit", *id)
289            .with_error(err.clone())
290            .log();
291
292            HttpResponse::BadRequest().json(serde_json::json!({
293                "error": err
294            }))
295        }
296    }
297}
298
299#[delete("/units/{id}")]
300pub async fn delete_unit(
301    state: web::Data<AppState>,
302    user: AuthenticatedUser,
303    id: web::Path<Uuid>,
304) -> impl Responder {
305    // Only SuperAdmin can delete units (structural data)
306    if user.role != "superadmin" {
307        return HttpResponse::Forbidden().json(serde_json::json!({
308            "error": "Only SuperAdmin can delete units (structural data)"
309        }));
310    }
311
312    match state.unit_use_cases.delete_unit(*id).await {
313        Ok(true) => {
314            // Audit log: successful unit deletion
315            AuditLogEntry::new(
316                AuditEventType::UnitDeleted,
317                Some(user.user_id),
318                user.organization_id,
319            )
320            .with_resource("Unit", *id)
321            .log();
322
323            HttpResponse::Ok().json(serde_json::json!({
324                "message": "Unit deleted successfully"
325            }))
326        }
327        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
328            "error": "Unit not found"
329        })),
330        Err(err) => {
331            // Audit log: failed unit deletion
332            AuditLogEntry::new(
333                AuditEventType::UnitDeleted,
334                Some(user.user_id),
335                user.organization_id,
336            )
337            .with_resource("Unit", *id)
338            .with_error(err.clone())
339            .log();
340
341            HttpResponse::BadRequest().json(serde_json::json!({
342                "error": err
343            }))
344        }
345    }
346}
347
348#[put("/units/{unit_id}/assign-owner/{owner_id}")]
349pub async fn assign_owner(
350    state: web::Data<AppState>,
351    user: AuthenticatedUser,
352    path: web::Path<(Uuid, Uuid)>,
353) -> impl Responder {
354    let (unit_id, owner_id) = path.into_inner();
355
356    match state.unit_use_cases.assign_owner(unit_id, owner_id).await {
357        Ok(unit) => {
358            // Audit log: successful unit assignment
359            AuditLogEntry::new(
360                AuditEventType::UnitAssignedToOwner,
361                Some(user.user_id),
362                user.organization_id,
363            )
364            .with_resource("Unit", unit_id)
365            .log();
366
367            HttpResponse::Ok().json(unit)
368        }
369        Err(err) => {
370            // Audit log: failed unit assignment
371            AuditLogEntry::new(
372                AuditEventType::UnitAssignedToOwner,
373                Some(user.user_id),
374                user.organization_id,
375            )
376            .with_resource("Unit", unit_id)
377            .with_error(err.clone())
378            .log();
379
380            HttpResponse::BadRequest().json(serde_json::json!({
381                "error": err
382            }))
383        }
384    }
385}