Skip to main content

koprogo_api/infrastructure/web/handlers/
unit_handlers.rs

1use crate::application::dto::{
2    CreateUnitDto, PageRequest, PageResponse, UnitResponseDto, UpdateUnitDto,
3};
4use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
5use crate::infrastructure::web::middleware::scope_guard::{
6    verify_acp_org_access, verify_building_org_access,
7};
8use crate::infrastructure::web::{AppState, AuthenticatedUser};
9use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
10use uuid::Uuid;
11use validator::Validate;
12
13#[utoipa::path(
14    post,
15    path = "/units",
16    tag = "Units",
17    summary = "Create a unit (lot) inside a building",
18    request_body = CreateUnitDto,
19    responses(
20        (status = 201, description = "Unit created", body = UnitResponseDto),
21        (status = 400, description = "Validation error, or unknown field in the body"),
22        (status = 403, description = "Forbidden (superadmin only — structural data)"),
23        (status = 404, description = "Building not found"),
24    ),
25    security(("bearer_auth" = []))
26)]
27#[post("/units")]
28pub async fn create_unit(
29    state: web::Data<AppState>,
30    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
31    dto: web::Json<CreateUnitDto>,
32) -> impl Responder {
33    // Le syndic crée les lots de SES immeubles ; le SuperAdmin, de tous.
34    // Même raisonnement que pour les immeubles : c'est le syndic qui
35    // retranscrit l'acte de base, et le contrôle porte sur le périmètre, pas
36    // sur le rôle. Voir building_handlers::create_building.
37    if !matches!(user.role.as_str(), "superadmin" | "syndic") {
38        return HttpResponse::Forbidden().json(serde_json::json!({
39            "error": "Seuls le syndic et le SuperAdmin créent des lots"
40        }));
41    }
42
43    if dto.building_id.is_empty() {
44        return HttpResponse::BadRequest().json(serde_json::json!({
45            "error": "Le lot doit désigner l'immeuble dont il relève (building_id)"
46        }));
47    }
48
49    let building_uuid = match Uuid::parse_str(&dto.building_id) {
50        Ok(id) => id,
51        Err(_) => {
52            return HttpResponse::BadRequest().json(serde_json::json!({
53                "error": "Invalid building ID format"
54            }));
55        }
56    };
57
58    // Le contrôle de périmètre : un syndic ne crée que dans les immeubles de
59    // son organisation, résolus via l'ACP parente. Le SuperAdmin passe outre.
60    if let Err(e) = crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
61        &user,
62        building_uuid,
63        &state.building_use_cases,
64        &state.acp_use_cases,
65    )
66    .await
67    {
68        return e.error_response();
69    }
70
71    // Story H15 — l'ACP d'un lot est celle de son building parent (#602).
72    // Elle reste acceptée dans le corps pour ne rien casser chez les
73    // appelants existants, mais elle n'est plus exigée : absente ou vide,
74    // on la lit sur le building, qui en est la source de vérité.
75    let acp_id = match dto
76        .acp_id
77        .as_deref()
78        .map(str::trim)
79        .filter(|s| !s.is_empty())
80    {
81        Some(raw) => match Uuid::parse_str(raw) {
82            Ok(id) => id,
83            Err(_) => {
84                return HttpResponse::BadRequest().json(serde_json::json!({
85                    "error": "Invalid acp_id format"
86                }));
87            }
88        },
89        None => match state.building_use_cases.get_building(building_uuid).await {
90            Ok(Some(building)) => match Uuid::parse_str(&building.acp_id) {
91                Ok(id) => id,
92                Err(_) => {
93                    return HttpResponse::InternalServerError().json(serde_json::json!({
94                        "error": "Invalid building.acp_id format"
95                    }));
96                }
97            },
98            Ok(None) => {
99                return HttpResponse::NotFound().json(serde_json::json!({
100                    "error": "Building not found"
101                }));
102            }
103            Err(err) => {
104                return HttpResponse::InternalServerError().json(serde_json::json!({
105                    "error": format!("Failed to resolve building ACP: {}", err)
106                }));
107            }
108        },
109    };
110
111    // Story H15 — audit org context : units.organization_id ayant été DROP,
112    // le scope org de l'audit vient du contexte utilisateur (cf. buildings).
113    let organization_id = user.organization_id;
114
115    if let Err(errors) = dto.validate() {
116        return HttpResponse::BadRequest().json(serde_json::json!({
117            "error": "Validation failed",
118            "details": errors.to_string()
119        }));
120    }
121
122    // L'ACP resolue ci-dessus fait foi : on la reinjecte dans le DTO pour que
123    // le use case travaille sur une valeur toujours presente et bien formee.
124    let mut dto = dto.into_inner();
125    dto.acp_id = Some(acp_id.to_string());
126
127    match state.unit_use_cases.create_unit(dto).await {
128        Ok(unit) => {
129            // Audit log: successful unit creation
130            AuditLogEntry::new(
131                AuditEventType::UnitCreated,
132                Some(user.user_id),
133                organization_id,
134            )
135            .with_resource("Unit", Uuid::parse_str(&unit.id).unwrap())
136            .log();
137
138            HttpResponse::Created().json(unit)
139        }
140        Err(err) => {
141            // Audit log: failed unit creation
142            AuditLogEntry::new(
143                AuditEventType::UnitCreated,
144                Some(user.user_id),
145                organization_id,
146            )
147            .with_error(err.clone())
148            .log();
149
150            HttpResponse::BadRequest().json(serde_json::json!({
151                "error": err
152            }))
153        }
154    }
155}
156
157#[utoipa::path(
158    get,
159    path = "/units/{id}",
160    tag = "Units",
161    summary = "Get a single unit",
162    params(("id" = Uuid, Path, description = "Unit identifier")),
163    responses(
164        (status = 200, description = "Unit", body = UnitResponseDto),
165        (status = 404, description = "Unit not found"),
166    ),
167    security(("bearer_auth" = []))
168)]
169#[get("/units/{id}")]
170pub async fn get_unit(
171    state: web::Data<AppState>,
172    user: AuthenticatedUser,
173    id: web::Path<Uuid>,
174) -> impl Responder {
175    match state.unit_use_cases.get_unit(*id).await {
176        Ok(Some(unit)) => {
177            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
178            if let Ok(building_id) = Uuid::parse_str(&unit.building_id) {
179                if let Ok(Some(building)) = state.building_use_cases.get_building(building_id).await
180                {
181                    let acp_id = match Uuid::parse_str(&building.acp_id) {
182                        Ok(id) => id,
183                        Err(_) => {
184                            return HttpResponse::InternalServerError().json(serde_json::json!({
185                                "error": "Invalid building.acp_id format"
186                            }));
187                        }
188                    };
189                    if let Err(err) =
190                        verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
191                    {
192                        return err.error_response();
193                    }
194                }
195            }
196            HttpResponse::Ok().json(unit)
197        }
198        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
199            "error": "Unit not found"
200        })),
201        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
202            "error": err
203        })),
204    }
205}
206
207#[utoipa::path(
208    get,
209    path = "/units",
210    tag = "Units",
211    summary = "List units visible to the authenticated user (paginated)",
212    responses(
213        (status = 200, description = "Units page"),
214        (status = 401, description = "User does not belong to an organization"),
215    ),
216    security(("bearer_auth" = []))
217)]
218#[get("/units")]
219pub async fn list_units(
220    state: web::Data<AppState>,
221    user: AuthenticatedUser,
222    page_request: web::Query<PageRequest>,
223) -> impl Responder {
224    let organization_id = user.organization_id;
225
226    match state
227        .unit_use_cases
228        .list_units_paginated(&page_request, organization_id)
229        .await
230    {
231        Ok((units, total)) => {
232            let response =
233                PageResponse::new(units, page_request.page, page_request.per_page, total);
234            HttpResponse::Ok().json(response)
235        }
236        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
237            "error": err
238        })),
239    }
240}
241
242#[utoipa::path(
243    get,
244    path = "/buildings/{building_id}/units",
245    tag = "Units",
246    summary = "List the units of a building",
247    params(("building_id" = Uuid, Path, description = "Building identifier")),
248    responses(
249        (status = 200, description = "Units", body = Vec<UnitResponseDto>),
250        (status = 403, description = "Forbidden (building outside the user scope)"),
251    ),
252    security(("bearer_auth" = []))
253)]
254#[get("/buildings/{building_id}/units")]
255pub async fn list_units_by_building(
256    state: web::Data<AppState>,
257    user: AuthenticatedUser,
258    building_id: web::Path<Uuid>,
259) -> impl Responder {
260    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
261    match state.building_use_cases.get_building(*building_id).await {
262        Ok(Some(building)) => {
263            let acp_id = match Uuid::parse_str(&building.acp_id) {
264                Ok(id) => id,
265                Err(_) => {
266                    return HttpResponse::InternalServerError().json(serde_json::json!({
267                        "error": "Invalid building.acp_id format"
268                    }));
269                }
270            };
271            if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
272                return err.error_response();
273            }
274        }
275        Ok(None) => {
276            return HttpResponse::NotFound().json(serde_json::json!({
277                "error": "Building not found"
278            }));
279        }
280        Err(err) => {
281            return HttpResponse::InternalServerError().json(serde_json::json!({
282                "error": err
283            }));
284        }
285    }
286
287    match state
288        .unit_use_cases
289        .list_units_by_building(*building_id)
290        .await
291    {
292        Ok(units) => HttpResponse::Ok().json(units),
293        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
294            "error": err
295        })),
296    }
297}
298
299#[utoipa::path(
300    put,
301    path = "/units/{id}",
302    tag = "Units",
303    summary = "Update a unit",
304    description = "N'accepte PAS `owner_id` : la relation lot/proprietaire vit dans \
305`unit_owners` (routes `/unit-owners`), qui porte les quotites et les dates de \
306detention. `units.owner_id` est deprecie depuis la migration \
307`20250127000000_refactor_owners_multitenancy`. Un corps portant `owner_id` \
308recevait auparavant un 200 en jetant le champ ; il recoit desormais un 400.",
309    params(("id" = Uuid, Path, description = "Unit identifier")),
310    request_body = UpdateUnitDto,
311    responses(
312        (status = 200, description = "Unit updated", body = UnitResponseDto),
313        (status = 400, description = "Validation error, or unknown field (e.g. `owner_id`)"),
314        (status = 403, description = "Forbidden (superadmin only — quotites are structural)"),
315        (status = 404, description = "Unit not found"),
316    ),
317    security(("bearer_auth" = []))
318)]
319#[put("/units/{id}")]
320pub async fn update_unit(
321    state: web::Data<AppState>,
322    user: AuthenticatedUser,
323    id: web::Path<Uuid>,
324    dto: web::Json<UpdateUnitDto>,
325) -> impl Responder {
326    // Only SuperAdmin can update units (structural data including quotités)
327    if !user.is_superadmin() {
328        return HttpResponse::Forbidden().json(serde_json::json!({
329            "error": "Only SuperAdmin can update units (structural data including quotités)"
330        }));
331    }
332
333    if let Err(errors) = dto.validate() {
334        return HttpResponse::BadRequest().json(serde_json::json!({
335            "error": "Validation failed",
336            "details": errors.to_string()
337        }));
338    }
339
340    // Verify the user owns the unit (via building organization check)
341    if !user.is_superadmin() {
342        match state.unit_use_cases.get_unit(*id).await {
343            Ok(Some(unit)) => {
344                // Get the building to check organization
345                let building_id = match Uuid::parse_str(&unit.building_id) {
346                    Ok(id) => id,
347                    Err(_) => {
348                        return HttpResponse::InternalServerError().json(serde_json::json!({
349                            "error": "Invalid building_id"
350                        }));
351                    }
352                };
353
354                match state.building_use_cases.get_building(building_id).await {
355                    Ok(Some(building)) => {
356                        // Hotfix #603 — branch is unreachable (superadmin-only guard
357                        // above) but defensive verify_acp_org_access in case the
358                        // guard is relaxed in the future.
359                        let acp_id = match Uuid::parse_str(&building.acp_id) {
360                            Ok(id) => id,
361                            Err(_) => {
362                                return HttpResponse::InternalServerError().json(
363                                    serde_json::json!({
364                                        "error": "Invalid building.acp_id format"
365                                    }),
366                                );
367                            }
368                        };
369                        if let Err(err) =
370                            verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
371                        {
372                            return err.error_response();
373                        }
374                    }
375                    Ok(None) => {
376                        return HttpResponse::NotFound().json(serde_json::json!({
377                            "error": "Building not found"
378                        }));
379                    }
380                    Err(err) => {
381                        return HttpResponse::InternalServerError().json(serde_json::json!({
382                            "error": err
383                        }));
384                    }
385                }
386            }
387            Ok(None) => {
388                return HttpResponse::NotFound().json(serde_json::json!({
389                    "error": "Unit not found"
390                }));
391            }
392            Err(err) => {
393                return HttpResponse::InternalServerError().json(serde_json::json!({
394                    "error": err
395                }));
396            }
397        }
398    }
399
400    match state
401        .unit_use_cases
402        .update_unit(*id, dto.into_inner())
403        .await
404    {
405        Ok(unit) => {
406            // Audit log: successful unit update
407            AuditLogEntry::new(
408                AuditEventType::UnitUpdated,
409                Some(user.user_id),
410                user.organization_id,
411            )
412            .with_resource("Unit", *id)
413            .log();
414
415            HttpResponse::Ok().json(unit)
416        }
417        Err(err) => {
418            // Audit log: failed unit update
419            AuditLogEntry::new(
420                AuditEventType::UnitUpdated,
421                Some(user.user_id),
422                user.organization_id,
423            )
424            .with_resource("Unit", *id)
425            .with_error(err.clone())
426            .log();
427
428            HttpResponse::BadRequest().json(serde_json::json!({
429                "error": err
430            }))
431        }
432    }
433}
434
435#[utoipa::path(
436    delete,
437    path = "/units/{id}",
438    tag = "Units",
439    summary = "Delete a unit",
440    params(("id" = Uuid, Path, description = "Unit identifier")),
441    responses(
442        (status = 204, description = "Unit deleted"),
443        (status = 403, description = "Forbidden (superadmin only)"),
444        (status = 404, description = "Unit not found"),
445    ),
446    security(("bearer_auth" = []))
447)]
448#[delete("/units/{id}")]
449pub async fn delete_unit(
450    state: web::Data<AppState>,
451    user: AuthenticatedUser,
452    id: web::Path<Uuid>,
453) -> impl Responder {
454    // Only SuperAdmin can delete units (structural data)
455    if !user.is_superadmin() {
456        return HttpResponse::Forbidden().json(serde_json::json!({
457            "error": "Only SuperAdmin can delete units (structural data)"
458        }));
459    }
460
461    match state.unit_use_cases.delete_unit(*id).await {
462        Ok(true) => {
463            // Audit log: successful unit deletion
464            AuditLogEntry::new(
465                AuditEventType::UnitDeleted,
466                Some(user.user_id),
467                user.organization_id,
468            )
469            .with_resource("Unit", *id)
470            .log();
471
472            HttpResponse::Ok().json(serde_json::json!({
473                "message": "Unit deleted successfully"
474            }))
475        }
476        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
477            "error": "Unit not found"
478        })),
479        Err(err) => {
480            // Audit log: failed unit deletion
481            AuditLogEntry::new(
482                AuditEventType::UnitDeleted,
483                Some(user.user_id),
484                user.organization_id,
485            )
486            .with_resource("Unit", *id)
487            .with_error(err.clone())
488            .log();
489
490            HttpResponse::BadRequest().json(serde_json::json!({
491                "error": err
492            }))
493        }
494    }
495}
496
497#[utoipa::path(
498    put,
499    path = "/units/{unit_id}/assign-owner/{owner_id}",
500    tag = "Units",
501    summary = "Assign an owner to a unit",
502    params(
503        ("unit_id" = Uuid, Path, description = "Unit identifier"),
504        ("owner_id" = Uuid, Path, description = "Owner identifier"),
505    ),
506    responses(
507        (status = 200, description = "Owner assigned", body = UnitResponseDto),
508        (status = 400, description = "Assignment refused by the domain"),
509    ),
510    security(("bearer_auth" = []))
511)]
512#[put("/units/{unit_id}/assign-owner/{owner_id}")]
513pub async fn assign_owner(
514    state: web::Data<AppState>,
515    user: AuthenticatedUser,
516    path: web::Path<(Uuid, Uuid)>,
517) -> impl Responder {
518    let (unit_id, owner_id) = path.into_inner();
519
520    // Cloisonnement AVANT l'affectation (#864).
521    //
522    // Le lot et le propriétaire partaient seuls au cas d'usage. Affecter un
523    // lot d'une autre copropriété à un propriétaire arbitraire change QUI
524    // détient QUOI — donc les quotités, donc les appels de fonds, donc le
525    // droit de vote en assemblée (Art. 3.87). Le journal d'audit enregistrait
526    // le geste comme régulier.
527    match state.unit_use_cases.get_unit(unit_id).await {
528        Ok(Some(unite)) => {
529            // Le lot ne porte pas d'ACP dans sa réponse — il porte son
530            // immeuble, qui porte l'ACP. On emprunte donc la même chaîne que
531            // partout ailleurs, via `verify_building_org_access`.
532            //
533            // Un immeuble illisible REFUSE : un lot qu'on ne sait pas
534            // rattacher est un lot dont on ne peut pas dire qu'il relève du
535            // mandat de l'appelant.
536            let building_id = match Uuid::parse_str(&unite.building_id) {
537                Ok(id) => id,
538                Err(_) => {
539                    return HttpResponse::Forbidden().json(serde_json::json!({
540                        "error": "Impossible de rattacher ce lot à un immeuble"
541                    }))
542                }
543            };
544            if let Err(err) = verify_building_org_access(
545                &user,
546                building_id,
547                &state.building_use_cases,
548                &state.acp_use_cases,
549            )
550            .await
551            {
552                return err.error_response();
553            }
554        }
555        Ok(None) => {
556            return HttpResponse::NotFound().json(serde_json::json!({
557                "error": "Unit not found"
558            }))
559        }
560        Err(err) => {
561            return HttpResponse::InternalServerError().json(serde_json::json!({
562                "error": err.to_string()
563            }))
564        }
565    }
566
567    match state.unit_use_cases.assign_owner(unit_id, owner_id).await {
568        Ok(unit) => {
569            // Audit log: successful unit assignment
570            AuditLogEntry::new(
571                AuditEventType::UnitAssignedToOwner,
572                Some(user.user_id),
573                user.organization_id,
574            )
575            .with_resource("Unit", unit_id)
576            .log();
577
578            HttpResponse::Ok().json(unit)
579        }
580        Err(err) => {
581            // Audit log: failed unit assignment
582            AuditLogEntry::new(
583                AuditEventType::UnitAssignedToOwner,
584                Some(user.user_id),
585                user.organization_id,
586            )
587            .with_resource("Unit", unit_id)
588            .with_error(err.clone())
589            .log();
590
591            HttpResponse::BadRequest().json(serde_json::json!({
592                "error": err
593            }))
594        }
595    }
596}