Skip to main content

koprogo_api/infrastructure/web/handlers/
cdc_handlers.rs

1//! Handlers HTTP du conseil de copropriété — Story 4.7 (#582).
2
3use crate::application::dto::{CreateBoardAlertDto, ElectCdcMembersDto};
4use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
5use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
6use crate::infrastructure::web::{AppState, AuthenticatedUser};
7use actix_web::{get, post, web, HttpResponse, Responder, ResponseError};
8use uuid::Uuid;
9
10/// Résout l'`owner_id` de l'utilisateur authentifié — un membre du conseil
11/// est nécessairement un copropriétaire (Art. 3.90 §1er CC).
12/// `Box<HttpResponse>` : clippy refuse un variant `Err` de 128 octets
13/// (`result_large_err`), et il a raison — chaque appel déplacerait la
14/// réponse entière sur la pile, y compris sur le chemin nominal où elle
15/// n'existe pas. Le coût du déréférencement est sur le chemin d'ERREUR.
16async fn owner_id_of(
17    state: &AppState,
18    user: &AuthenticatedUser,
19) -> Result<Uuid, Box<HttpResponse>> {
20    match state
21        .owner_use_cases
22        .find_owner_by_user_id(user.user_id)
23        .await
24    {
25        Ok(Some(owner)) => Uuid::parse_str(&owner.id).map_err(|_| {
26            Box::new(HttpResponse::InternalServerError().json(serde_json::json!({
27                "error": "Invalid owner id"
28            })))
29        }),
30        Ok(None) => Err(Box::new(HttpResponse::Forbidden().json(
31            serde_json::json!({
32                "error": "Cette action est réservée aux membres du conseil de copropriété, \
33                          qui sont copropriétaires (Art. 3.90 §1er CC). Votre compte n'est \
34                          rattaché à aucune fiche de copropriétaire."
35            }),
36        ))),
37        Err(err) => Err(Box::new(HttpResponse::InternalServerError().json(
38            serde_json::json!({ "error": format!("Database error: {}", err) }),
39        ))),
40    }
41}
42
43/// Émet une alerte du conseil de copropriété à destination de la prochaine AG.
44#[utoipa::path(
45    post,
46    path = "/buildings/{building_id}/cdc/alerts",
47    tag = "Cdc",
48    summary = "Émettre une alerte du conseil de copropriété",
49    params(("building_id" = Uuid, Path, description = "UUID de l'immeuble")),
50    request_body = CreateBoardAlertDto,
51    responses(
52        (status = 201, description = "Alerte émise", body = crate::application::dto::BoardAlertResponseDto),
53        (status = 403, description = "Hors mandat ou hors portée"),
54        (status = 422, description = "Sévérité ou AG cible invalide"),
55    ),
56    security(("bearer_auth" = []))
57)]
58#[post("/buildings/{building_id}/cdc/alerts")]
59pub async fn create_cdc_alert(
60    state: web::Data<AppState>,
61    user: AuthenticatedUser,
62    building_id: web::Path<Uuid>,
63    request: web::Json<CreateBoardAlertDto>,
64) -> impl Responder {
65    let owner_id = match owner_id_of(&state, &user).await {
66        Ok(id) => id,
67        Err(resp) => return *resp,
68    };
69    let building_id = building_id.into_inner();
70
71    match state
72        .cdc_use_cases
73        .create_alert(owner_id, building_id, request.into_inner())
74        .await
75    {
76        Ok(alert) => {
77            if let Ok(alert_id) = Uuid::parse_str(&alert.id) {
78                AuditLogEntry::new(
79                    AuditEventType::CdcAlertCreated,
80                    Some(user.user_id),
81                    user.organization_id,
82                )
83                .with_resource("BoardAlert", alert_id)
84                .log();
85            }
86            HttpResponse::Created().json(alert)
87        }
88        Err(err) => {
89            AuditLogEntry::new(
90                AuditEventType::CdcAlertCreated,
91                Some(user.user_id),
92                user.organization_id,
93            )
94            .with_error(err.to_string())
95            .log();
96            err.error_response()
97        }
98    }
99}
100
101/// Liste les alertes du conseil visibles à une AG donnée.
102#[utoipa::path(
103    get,
104    path = "/meetings/{meeting_id}/cdc/alerts",
105    tag = "Cdc",
106    summary = "Alertes du conseil rattachées à une AG",
107    params(("meeting_id" = Uuid, Path, description = "UUID de l'assemblée")),
108    responses(
109        (status = 200, description = "Alertes de l'AG", body = Vec<crate::application::dto::BoardAlertResponseDto>),
110        (status = 403, description = "Hors portée (cloisonnement #882)"),
111        (status = 404, description = "Assemblée introuvable"),
112    ),
113    security(("bearer_auth" = []))
114)]
115#[get("/meetings/{meeting_id}/cdc/alerts")]
116pub async fn list_cdc_alerts_for_meeting(
117    state: web::Data<AppState>,
118    user: AuthenticatedUser,
119    meeting_id: web::Path<Uuid>,
120) -> impl Responder {
121    let meeting_id = meeting_id.into_inner();
122
123    // Cloisonnement AVANT la lecture (#882).
124    //
125    // Cette route prenait `_user` — l'identité DÉCLARÉE inutile par le
126    // souligné — et rendait les alertes du conseil de n'importe quelle AG à
127    // quiconque en connaissait l'UUID.
128    //
129    // Les alertes du conseil de copropriété ne sont pas des données
130    // anodines : le conseil contrôle la gestion du syndic (Art. 3.90 § 1er),
131    // et ses alertes disent où il estime que quelque chose cloche. Les lire
132    // depuis un autre cabinet, c'est lire un audit interne.
133    //
134    // La chaîne est celle du reste du dépôt : AG → immeuble → ACP →
135    // organisation.
136    let meeting = match state.meeting_use_cases.get_meeting(meeting_id).await {
137        Ok(Some(m)) => m,
138        Ok(None) => {
139            return HttpResponse::NotFound().json(serde_json::json!({
140                "error": "Meeting not found"
141            }))
142        }
143        Err(err) => {
144            return HttpResponse::InternalServerError().json(serde_json::json!({
145                "error": err.to_string()
146            }))
147        }
148    };
149    // Un immeuble ou une ACP illisibles REFUSENT : une AG qu'on ne sait pas
150    // rattacher est une AG dont on ne peut pas dire qu'elle relève du mandat
151    // de l'appelant.
152    let acp_id = match state
153        .building_use_cases
154        .get_building(meeting.building_id)
155        .await
156    {
157        Ok(Some(b)) => match Uuid::parse_str(&b.acp_id) {
158            Ok(id) => id,
159            Err(_) => {
160                return HttpResponse::Forbidden().json(serde_json::json!({
161                    "error": "Impossible de rattacher cette assemblée à une ACP"
162                }))
163            }
164        },
165        _ => {
166            return HttpResponse::Forbidden().json(serde_json::json!({
167                "error": "Impossible de rattacher cette assemblée à une ACP"
168            }))
169        }
170    };
171    if let Err(err) = verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await {
172        return err.error_response();
173    }
174
175    match state
176        .cdc_use_cases
177        .list_alerts_for_meeting(meeting_id)
178        .await
179    {
180        Ok(alerts) => HttpResponse::Ok().json(alerts),
181        Err(err) => err.error_response(),
182    }
183}
184
185/// Élit les membres du conseil de copropriété à l'issue d'une AG clôturée.
186#[utoipa::path(
187    post,
188    path = "/buildings/{building_id}/cdc/elections",
189    tag = "Cdc",
190    summary = "Élire les membres du conseil de copropriété",
191    params(("building_id" = Uuid, Path, description = "UUID de l'immeuble")),
192    request_body = ElectCdcMembersDto,
193    responses(
194        (status = 201, description = "Membres élus", body = Vec<crate::application::dto::BoardMemberResponseDto>),
195        (status = 403, description = "Hors portée"),
196        (status = 422, description = "Quorum non atteint ou candidature invalide"),
197    ),
198    security(("bearer_auth" = []))
199)]
200#[post("/buildings/{building_id}/cdc/elections")]
201pub async fn elect_cdc_members(
202    state: web::Data<AppState>,
203    user: AuthenticatedUser,
204    building_id: web::Path<Uuid>,
205    request: web::Json<ElectCdcMembersDto>,
206) -> impl Responder {
207    match state
208        .cdc_use_cases
209        .elect_members(building_id.into_inner(), request.into_inner())
210        .await
211    {
212        Ok(members) => {
213            AuditLogEntry::new(
214                AuditEventType::CdcMembersElected,
215                Some(user.user_id),
216                user.organization_id,
217            )
218            .log();
219            HttpResponse::Created().json(members)
220        }
221        Err(err) => {
222            AuditLogEntry::new(
223                AuditEventType::CdcMembersElected,
224                Some(user.user_id),
225                user.organization_id,
226            )
227            .with_error(err.to_string())
228            .log();
229            err.error_response()
230        }
231    }
232}