Skip to main content

koprogo_api/infrastructure/web/handlers/
acp_handlers.rs

1//! Handlers Actix pour `/acps` — Story 1.1.
2//!
3//! Endpoints :
4//! - POST   `/acps`        : create (admin)
5//! - GET    `/acps`        : list filtré par rôle
6//! - GET    `/acps/{id}`   : get + scope guard
7//! - PUT    `/acps/{id}`   : update (admin + scope)
8//! - DELETE `/acps/{id}`   : archive (admin + scope)
9//!
10//! Le mapping `AuthenticatedUser → AcpCaller` se fait ici (couche infra),
11//! pour garder le use-case 100% testable en pur Rust.
12//!
13//! Audit : `infrastructure::audit::AuditLogEntry` consigne chaque mutation
14//! réussie ET chaque échec (pattern Building, traçabilité INV-24).
15
16use crate::application::dto::{CreateAcpDto, UpdateAcpDto};
17use crate::application::use_cases::acp_use_cases::AcpCaller;
18use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
19use crate::infrastructure::web::{AppState, AuthenticatedUser};
20use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
21use uuid::Uuid;
22use validator::Validate;
23
24/// Map `AuthenticatedUser` → `AcpCaller` (story 1.1 — sera enrichi en 3.1
25/// quand les sous-rôles accountant.* apparaîtront).
26pub(crate) fn caller_from_user(user: &AuthenticatedUser) -> AcpCaller {
27    match user.role.to_lowercase().as_str() {
28        "superadmin" => AcpCaller::SuperAdmin,
29        "admin" => match user.organization_id {
30            Some(org) => AcpCaller::Admin {
31                organization_id: org,
32            },
33            None => AcpCaller::SuperAdmin, // admin sans org = traité comme superadmin lecture
34        },
35        "syndic" | "accountant" => match user.organization_id {
36            Some(org) => AcpCaller::Syndic {
37                organization_id: org,
38            },
39            None => AcpCaller::Owner {
40                user_id: user.user_id,
41            },
42        },
43        _ => AcpCaller::Owner {
44            user_id: user.user_id,
45        },
46    }
47}
48
49#[utoipa::path(
50    post,
51    path = "/acps",
52    tag = "Acps",
53    summary = "Create an ACP (Association des Copropriétaires)",
54    request_body = CreateAcpDto,
55    responses(
56        (status = 201, description = "ACP created", body = crate::application::dto::AcpResponseDto),
57        (status = 400, description = "Validation error"),
58        (status = 403, description = "Forbidden (non-admin)"),
59        (status = 422, description = "Domain validation error"),
60    ),
61    security(("bearer_auth" = []))
62)]
63#[post("/acps")]
64pub async fn create_acp(
65    state: web::Data<AppState>,
66    user: AuthenticatedUser,
67    dto: web::Json<CreateAcpDto>,
68) -> impl Responder {
69    if let Err(errors) = dto.validate() {
70        return HttpResponse::BadRequest().json(serde_json::json!({
71            "error": "Validation failed",
72            "details": errors.to_string(),
73            "kind": "validation",
74        }));
75    }
76
77    let caller = caller_from_user(&user);
78    match state
79        .acp_use_cases
80        .create_acp(&caller, dto.into_inner())
81        .await
82    {
83        Ok(resp) => {
84            // Audit OK
85            if let Ok(acp_uuid) = Uuid::parse_str(&resp.id) {
86                AuditLogEntry::new(
87                    AuditEventType::AcpCreated,
88                    Some(user.user_id),
89                    user.organization_id,
90                )
91                .with_resource("Acp", acp_uuid)
92                .log();
93            }
94            HttpResponse::Created().json(resp)
95        }
96        Err(err) => {
97            AuditLogEntry::new(
98                AuditEventType::AcpCreated,
99                Some(user.user_id),
100                user.organization_id,
101            )
102            .with_error(err.to_string())
103            .log();
104            err.error_response()
105        }
106    }
107}
108
109#[utoipa::path(
110    get,
111    path = "/acps",
112    tag = "Acps",
113    summary = "List ACPs visible to the authenticated user",
114    responses(
115        (status = 200, description = "List of ACPs", body = Vec<crate::application::dto::AcpResponseDto>),
116        (status = 401, description = "Unauthorized"),
117    ),
118    security(("bearer_auth" = []))
119)]
120#[get("/acps")]
121pub async fn list_acps(state: web::Data<AppState>, user: AuthenticatedUser) -> impl Responder {
122    let caller = caller_from_user(&user);
123    match state.acp_use_cases.list_acps(&caller).await {
124        Ok(list) => HttpResponse::Ok().json(list),
125        Err(err) => err.error_response(),
126    }
127}
128
129#[utoipa::path(
130    get,
131    path = "/acps/with-metrics",
132    tag = "Acps",
133    summary = "Les ACP du périmètre, avec leurs métriques agrégées",
134    description = "Sert la table « Mes ACP » du tableau de bord syndic : nombre \
135                   de blocs, lots encodés et déclarés, somme des quotités. \
136                   Séparée de `GET /acps` parce que les métriques coûtent \
137                   quatre sous-requêtes par ligne : un sélecteur qui n'a besoin \
138                   que des noms ne doit pas les payer.",
139    responses(
140        (status = 200, description = "Liste des ACP avec métriques"),
141        (status = 401, description = "Non authentifié"),
142    ),
143    security(("bearer_auth" = []))
144)]
145#[get("/acps/with-metrics")]
146pub async fn list_acps_with_metrics(
147    state: web::Data<AppState>,
148    user: AuthenticatedUser,
149) -> impl Responder {
150    let caller = caller_from_user(&user);
151    match state.acp_use_cases.list_acps_with_metrics(&caller).await {
152        Ok(list) => HttpResponse::Ok().json(list),
153        Err(err) => err.error_response(),
154    }
155}
156
157#[utoipa::path(
158    get,
159    path = "/acps/{id}",
160    tag = "Acps",
161    summary = "Get an ACP by id (scope-guarded)",
162    params(("id" = Uuid, Path, description = "ACP UUID")),
163    responses(
164        (status = 200, description = "ACP found", body = crate::application::dto::AcpResponseDto),
165        (status = 403, description = "Out of scope (AcpNotInScope)"),
166        (status = 404, description = "Not found"),
167    ),
168    security(("bearer_auth" = []))
169)]
170#[get("/acps/{id}")]
171pub async fn get_acp(
172    state: web::Data<AppState>,
173    user: AuthenticatedUser,
174    id: web::Path<Uuid>,
175) -> impl Responder {
176    let caller = caller_from_user(&user);
177    match state.acp_use_cases.get_acp(&caller, *id).await {
178        Ok(resp) => HttpResponse::Ok().json(resp),
179        Err(err) => err.error_response(),
180    }
181}
182
183#[utoipa::path(
184    put,
185    path = "/acps/{id}",
186    tag = "Acps",
187    summary = "Update an ACP (admin + scope)",
188    params(("id" = Uuid, Path, description = "ACP UUID")),
189    request_body = UpdateAcpDto,
190    responses(
191        (status = 200, description = "ACP updated", body = crate::application::dto::AcpResponseDto),
192        (status = 400, description = "Validation error"),
193        (status = 403, description = "Forbidden / out of scope"),
194        (status = 404, description = "Not found"),
195    ),
196    security(("bearer_auth" = []))
197)]
198#[put("/acps/{id}")]
199pub async fn update_acp(
200    state: web::Data<AppState>,
201    user: AuthenticatedUser,
202    id: web::Path<Uuid>,
203    dto: web::Json<UpdateAcpDto>,
204) -> impl Responder {
205    if let Err(errors) = dto.validate() {
206        return HttpResponse::BadRequest().json(serde_json::json!({
207            "error": "Validation failed",
208            "details": errors.to_string(),
209            "kind": "validation",
210        }));
211    }
212
213    let acp_id = *id;
214    let caller = caller_from_user(&user);
215    match state
216        .acp_use_cases
217        .update_acp(&caller, acp_id, dto.into_inner())
218        .await
219    {
220        Ok(resp) => {
221            AuditLogEntry::new(
222                AuditEventType::AcpUpdated,
223                Some(user.user_id),
224                user.organization_id,
225            )
226            .with_resource("Acp", acp_id)
227            .log();
228            HttpResponse::Ok().json(resp)
229        }
230        Err(err) => {
231            AuditLogEntry::new(
232                AuditEventType::AcpUpdated,
233                Some(user.user_id),
234                user.organization_id,
235            )
236            .with_resource("Acp", acp_id)
237            .with_error(err.to_string())
238            .log();
239            err.error_response()
240        }
241    }
242}
243
244#[utoipa::path(
245    delete,
246    path = "/acps/{id}",
247    tag = "Acps",
248    summary = "Archive (delete) an ACP (admin + scope)",
249    params(("id" = Uuid, Path, description = "ACP UUID")),
250    responses(
251        (status = 204, description = "ACP archived"),
252        (status = 403, description = "Forbidden / out of scope"),
253        (status = 404, description = "Not found"),
254        (
255            status = 409,
256            description = "ACP still carries buildings — detach or delete them first"
257        ),
258    ),
259    security(("bearer_auth" = []))
260)]
261#[delete("/acps/{id}")]
262pub async fn archive_acp(
263    state: web::Data<AppState>,
264    user: AuthenticatedUser,
265    id: web::Path<Uuid>,
266) -> impl Responder {
267    let acp_id = *id;
268    let caller = caller_from_user(&user);
269    match state.acp_use_cases.archive_acp(&caller, acp_id).await {
270        Ok(()) => {
271            AuditLogEntry::new(
272                AuditEventType::AcpArchived,
273                Some(user.user_id),
274                user.organization_id,
275            )
276            .with_resource("Acp", acp_id)
277            .log();
278            HttpResponse::NoContent().finish()
279        }
280        Err(err) => {
281            AuditLogEntry::new(
282                AuditEventType::AcpArchived,
283                Some(user.user_id),
284                user.organization_id,
285            )
286            .with_resource("Acp", acp_id)
287            .with_error(err.to_string())
288            .log();
289            err.error_response()
290        }
291    }
292}