Skip to main content

koprogo_api/infrastructure/web/handlers/
resolution_handlers.rs

1use crate::application::dto::{
2    CastVoteRequest, ChangeVoteRequest, CloseVotingRequest, CreateResolutionRequest,
3    ResolutionResponse, VoteResponse,
4};
5use crate::application::error::AppError;
6use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
7use crate::infrastructure::web::classification_erreurs::est_interdit;
8use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
9use crate::infrastructure::web::{AppState, AuthenticatedUser};
10use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
11use uuid::Uuid;
12
13// ==================== Resolution Endpoints ====================
14
15/// Clôturer un scrutin proclame une majorité opposable (Art. 3.87 §4 CC) :
16/// c'est un acte du syndic, jamais celui d'un copropriétaire qui vote.
17fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
18    match user.role.as_str() {
19        "syndic" | "superadmin" => Ok(()),
20        _ => Err(AppError::Forbidden(
21            "Seul le syndic peut clôturer le vote d'une résolution".to_string(),
22        )),
23    }
24}
25
26/// Vérifie que l'appelant a un mandat sur l'ACP dont relève une AG.
27///
28/// **Pourquoi une fonction et pas une ligne recopiée.** La chaîne à remonter
29/// fait quatre sauts — AG → immeuble → ACP → organisation. Recopiée dans
30/// chaque gestionnaire, elle finit par manquer là où on n'y a pas pensé :
31/// c'est exactement ce qui est arrivé à `list_meeting_resolutions` et
32/// `list_resolution_votes`, qui rendaient 200 sur les données d'une autre
33/// copropriété pendant que l'accès direct à la même ressource rendait 403.
34///
35/// Constaté en recette le 2026-09-06 (RN-2) : un syndic concurrent lisait le
36/// sens du vote de copropriétaires nommés, avec leur poids. Le vote en AG est
37/// confidentiel et nominatif : c'est une violation de données, pas un simple
38/// écart de périmètre.
39///
40/// Le maillon absent est traité comme un refus. Une AG dont on ne retrouve ni
41/// l'immeuble ni l'ACP ne peut pas être autorisée « par défaut » : c'est
42/// précisément le cas où l'on ne sait pas à qui elle appartient.
43///
44/// Rend `None` quand l'accès est accordé, `Some(reponse)` quand il est refusé.
45/// Un `Result<(), HttpResponse>` dirait la même chose, mais clippy le refuse à
46/// juste titre : `HttpResponse` est volumineux, et le porter dans la variante
47/// d'erreur alourdirait chaque valeur de retour, y compris sur le chemin
48/// nominal.
49async fn verifier_mandat_sur_ag(
50    state: &web::Data<AppState>,
51    user: &AuthenticatedUser,
52    meeting_id: Uuid,
53) -> Option<HttpResponse> {
54    if user.is_superadmin() {
55        return None;
56    }
57    let introuvable = || {
58        HttpResponse::NotFound().json(serde_json::json!({
59            "error": "Meeting not found"
60        }))
61    };
62    let Ok(Some(meeting)) = state.meeting_use_cases.get_meeting(meeting_id).await else {
63        return Some(introuvable());
64    };
65    let Ok(Some(building)) = state
66        .building_use_cases
67        .get_building(meeting.building_id)
68        .await
69    else {
70        return Some(introuvable());
71    };
72    let Ok(acp_id) = Uuid::parse_str(&building.acp_id) else {
73        return Some(HttpResponse::InternalServerError().json(serde_json::json!({
74            "error": "Invalid building.acp_id format"
75        })));
76    };
77    verify_acp_org_access(user, acp_id, &state.acp_use_cases)
78        .await
79        .err()
80        .map(|err| err.error_response())
81}
82
83#[utoipa::path(
84    post,
85    path = "/meetings/{meeting_id}/resolutions",
86    tag = "Resolutions",
87    summary = "Create a resolution for a meeting",
88    params(
89        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
90    ),
91    request_body = CreateResolutionRequest,
92    responses(
93        (status = 201, description = "Resolution created"),
94        (status = 400, description = "Bad Request"),
95        (status = 401, description = "Unauthorized"),
96    ),
97    security(("bearer_auth" = []))
98)]
99#[post("/meetings/{meeting_id}/resolutions")]
100pub async fn create_resolution(
101    state: web::Data<AppState>,
102    user: AuthenticatedUser,
103    meeting_id: web::Path<Uuid>,
104    request: web::Json<CreateResolutionRequest>,
105) -> impl Responder {
106    let organization_id = match user.require_organization() {
107        Ok(org_id) => org_id,
108        Err(e) => {
109            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
110        }
111    };
112
113    let meeting_id = *meeting_id;
114
115    match state
116        .resolution_use_cases
117        .create_resolution(
118            meeting_id,
119            request.title.clone(),
120            request.description.clone(),
121            request.resolution_type.clone(),
122            request.majority_required.clone(),
123            request.agenda_item_index,
124        )
125        .await
126    {
127        Ok(resolution) => {
128            AuditLogEntry::new(
129                AuditEventType::ResolutionCreated,
130                Some(user.user_id),
131                Some(organization_id),
132            )
133            .with_resource("Resolution", resolution.id)
134            .log();
135
136            HttpResponse::Created().json(ResolutionResponse::from(resolution))
137        }
138        Err(err) => {
139            AuditLogEntry::new(
140                AuditEventType::ResolutionCreated,
141                Some(user.user_id),
142                Some(organization_id),
143            )
144            .with_error(err.clone())
145            .log();
146
147            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
148        }
149    }
150}
151
152#[utoipa::path(
153    get,
154    path = "/resolutions/{id}",
155    tag = "Resolutions",
156    summary = "Get resolution by ID",
157    params(
158        ("id" = Uuid, Path, description = "Resolution UUID")
159    ),
160    responses(
161        (status = 200, description = "Resolution found"),
162        (status = 404, description = "Resolution not found"),
163        (status = 500, description = "Internal Server Error"),
164    ),
165    security(("bearer_auth" = []))
166)]
167#[get("/resolutions/{id}")]
168pub async fn get_resolution(
169    state: web::Data<AppState>,
170    user: AuthenticatedUser,
171    id: web::Path<Uuid>,
172) -> impl Responder {
173    match state.resolution_use_cases.get_resolution(*id).await {
174        Ok(Some(resolution)) => {
175            // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
176            // Resolution → Meeting → Building → Acp → Organization
177            if let Ok(Some(meeting)) = state
178                .meeting_use_cases
179                .get_meeting(resolution.meeting_id)
180                .await
181            {
182                if let Ok(Some(building)) = state
183                    .building_use_cases
184                    .get_building(meeting.building_id)
185                    .await
186                {
187                    let acp_id = match Uuid::parse_str(&building.acp_id) {
188                        Ok(id) => id,
189                        Err(_) => {
190                            return HttpResponse::InternalServerError().json(serde_json::json!({
191                                "error": "Invalid building.acp_id format"
192                            }));
193                        }
194                    };
195                    if let Err(err) =
196                        verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
197                    {
198                        return err.error_response();
199                    }
200                }
201            }
202            HttpResponse::Ok().json(ResolutionResponse::from(resolution))
203        }
204        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
205            "error": "Resolution not found"
206        })),
207        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
208            "error": err
209        })),
210    }
211}
212
213#[utoipa::path(
214    get,
215    path = "/meetings/{meeting_id}/resolutions",
216    tag = "Resolutions",
217    summary = "List all resolutions for a meeting",
218    params(
219        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
220    ),
221    responses(
222        (status = 200, description = "List of resolutions"),
223        (status = 500, description = "Internal Server Error"),
224    ),
225    security(("bearer_auth" = []))
226)]
227#[get("/meetings/{meeting_id}/resolutions")]
228pub async fn list_meeting_resolutions(
229    state: web::Data<AppState>,
230    user: AuthenticatedUser,
231    meeting_id: web::Path<Uuid>,
232) -> impl Responder {
233    if let Some(refus) = verifier_mandat_sur_ag(&state, &user, *meeting_id).await {
234        return refus;
235    }
236    match state
237        .resolution_use_cases
238        .get_meeting_resolutions(*meeting_id)
239        .await
240    {
241        Ok(resolutions) => {
242            let responses: Vec<ResolutionResponse> = resolutions
243                .into_iter()
244                .map(ResolutionResponse::from)
245                .collect();
246            HttpResponse::Ok().json(responses)
247        }
248        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
249            "error": err
250        })),
251    }
252}
253
254#[utoipa::path(
255    delete,
256    path = "/resolutions/{id}",
257    tag = "Resolutions",
258    summary = "Delete a resolution",
259    params(
260        ("id" = Uuid, Path, description = "Resolution UUID")
261    ),
262    responses(
263        (status = 204, description = "Resolution deleted"),
264        (status = 400, description = "Bad Request"),
265        (status = 401, description = "Unauthorized"),
266        (status = 404, description = "Resolution not found"),
267    ),
268    security(("bearer_auth" = []))
269)]
270#[delete("/resolutions/{id}")]
271pub async fn delete_resolution(
272    state: web::Data<AppState>,
273    user: AuthenticatedUser,
274    id: web::Path<Uuid>,
275) -> impl Responder {
276    let organization_id = match user.require_organization() {
277        Ok(org_id) => org_id,
278        Err(e) => {
279            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
280        }
281    };
282
283    match state.resolution_use_cases.delete_resolution(*id).await {
284        Ok(true) => {
285            AuditLogEntry::new(
286                AuditEventType::ResolutionDeleted,
287                Some(user.user_id),
288                Some(organization_id),
289            )
290            .with_resource("Resolution", *id)
291            .log();
292
293            HttpResponse::NoContent().finish()
294        }
295        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
296            "error": "Resolution not found"
297        })),
298        Err(err) => {
299            AuditLogEntry::new(
300                AuditEventType::ResolutionDeleted,
301                Some(user.user_id),
302                Some(organization_id),
303            )
304            .with_error(err.to_string())
305            .log();
306
307            err.error_response()
308        }
309    }
310}
311
312// ==================== Vote Endpoints ====================
313
314#[utoipa::path(
315    post,
316    path = "/resolutions/{resolution_id}/vote",
317    tag = "Resolutions",
318    summary = "Cast a vote on a resolution",
319    params(
320        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
321    ),
322    request_body = CastVoteRequest,
323    responses(
324        (status = 201, description = "Vote cast"),
325        (status = 400, description = "Bad Request"),
326        (status = 401, description = "Unauthorized"),
327    ),
328    security(("bearer_auth" = []))
329)]
330#[post("/resolutions/{resolution_id}/vote")]
331pub async fn cast_vote(
332    state: web::Data<AppState>,
333    user: AuthenticatedUser,
334    resolution_id: web::Path<Uuid>,
335    request: web::Json<CastVoteRequest>,
336) -> impl Responder {
337    let organization_id = match user.require_organization() {
338        Ok(org_id) => org_id,
339        Err(e) => {
340            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
341        }
342    };
343
344    // #850 — QUI vote est résolu depuis l'utilisateur authentifié, jamais
345    // repris tel quel du corps de la requête. `AuthenticatedUser` était
346    // présent dans cette signature depuis toujours, mais ne servait qu'à
347    // `require_organization()` et au journal d'audit : rien ne rapprochait
348    // son identité de `request.owner_id`. Même geste que #849
349    // (`poll_handlers.rs::cast_poll_vote`) : résoudre le copropriétaire, pas
350    // l'utilisateur.
351    let caller_owner_id = match state
352        .owner_use_cases
353        .find_owner_by_user_id(user.user_id)
354        .await
355    {
356        Ok(Some(owner)) => match Uuid::parse_str(&owner.id) {
357            Ok(id) => id,
358            Err(e) => {
359                return HttpResponse::InternalServerError().json(serde_json::json!({
360                    "error": format!("Identifiant de copropriétaire illisible : {}", e)
361                }));
362            }
363        },
364        Ok(None) => {
365            // Un utilisateur sans fiche de copropriétaire ne peut voter pour
366            // personne — ni pour lui-même (il n'a pas de lot), ni comme
367            // mandataire (aucune identité à opposer à `caller_owner_id`).
368            //
369            // Ceci ferme, pour l'instant, la question que #850 pose sans la
370            // trancher : le syndic doit-il pouvoir saisir des votes en séance ?
371            // Cette route ne le permet plus. Si l'usage l'exige, il faut une
372            // route dédiée, réservée au syndic, journalisée comme saisie pour
373            // compte de tiers et soumise à la limite des procurations — pas
374            // rouvrir celle-ci.
375            return HttpResponse::Forbidden().json(serde_json::json!({
376                "error": "Aucune fiche de copropriétaire n'est rattachée à ce compte : \
377                          voter à une assemblée est réservé aux copropriétaires.",
378                "kind": "owner_not_linked"
379            }));
380        }
381        Err(e) => {
382            return HttpResponse::InternalServerError().json(serde_json::json!({
383                "error": format!("Failed to resolve owner for user: {}", e)
384            }));
385        }
386    };
387
388    match state
389        .resolution_use_cases
390        .cast_vote(
391            *resolution_id,
392            request.owner_id,
393            request.unit_id,
394            request.vote_choice.clone(),
395            // `request.voting_power` n'est PAS transmis : le cas d'usage relit
396            // la quotité du lot. Accepter ici une puissance déclarée sans s'en
397            // servir ressemblerait à un contrôle — c'est exactement ce que #850
398            // reproche à cette route.
399            request.proxy_owner_id,
400            request.auth_method,
401            caller_owner_id,
402        )
403        .await
404    {
405        Ok(vote) => {
406            AuditLogEntry::new(
407                AuditEventType::VoteCast,
408                Some(user.user_id),
409                Some(organization_id),
410            )
411            .with_resource("Vote", vote.id)
412            .with_metadata(serde_json::json!({
413                "resolution_id": *resolution_id,
414                "vote_choice": format!("{:?}", vote.vote_choice)
415            }))
416            .log();
417
418            HttpResponse::Created().json(VoteResponse::from(vote))
419        }
420        Err(err) => {
421            AuditLogEntry::new(
422                AuditEventType::VoteCast,
423                Some(user.user_id),
424                Some(organization_id),
425            )
426            .with_error(err.clone())
427            .log();
428
429            // Le refus de l'Art. 3.87 § 1er porte son code, et repart en 422.
430            //
431            // `AppError::VotingRightSuspended` existe, avec son statut 422 et
432            // son payload `{code, unit_id}` que l'interface consomme pour dire
433            // au syndic QUEL lot est concerné. Le pont
434            // `From<VotingRightSuspendedError> for String` existe aussi, et son
435            // commentaire annonce un préfixe « parsable par le gate vote ».
436            //
437            // Les deux bouts étaient écrits. Personne ne les avait reliés : le
438            // refus repartait en 400 avec une chaîne plate, et le badge du
439            // frontend n'avait jamais rien à consommer. Mesuré en exerçant le
440            // scénario d'indivision de #848 pour la première fois.
441            //
442            // Classer par préfixe est ce que #762 veut faire disparaître. Ici
443            // le préfixe a été posé EXPRÈS comme pont vers `Result<_, String>`,
444            // et s'en passer suppose de typer l'erreur du cas d'usage — soit la
445            // migration des 1263 `Result<_, String>` de #555.
446            if let Some(reste) = err.strip_prefix("VOTING_RIGHT_SUSPENDED: unit ") {
447                if let Ok(unit_id) = uuid::Uuid::parse_str(reste.trim()) {
448                    return crate::application::error::AppError::VotingRightSuspended { unit_id }
449                        .error_response();
450                }
451            }
452
453            // Story 4.2 (#48) — `auth_method` absent (422) ou insuffisant pour
454            // le mode distanciel de l'AG (403). Même geste que
455            // VOTING_RIGHT_SUSPENDED ci-dessus : le préfixe posé côté cas
456            // d'usage (`application/error.rs::From<VoteAuthError> for String`)
457            // est reconnu ici pour reconstruire l'erreur typée.
458            if err == "VOTE_AUTH_METHOD_REQUIRED" {
459                return crate::application::error::AppError::VoteAuthMethodRequired
460                    .error_response();
461            }
462            if let Some(reste) = err.strip_prefix("VOTE_AUTH_INSUFFICIENT:") {
463                let mut parts = reste.splitn(2, ':');
464                if let (Some(mode), Some(auth_method)) = (parts.next(), parts.next()) {
465                    return crate::application::error::AppError::VoteAuthInsufficient {
466                        mode: mode.to_string(),
467                        auth_method: auth_method.to_string(),
468                    }
469                    .error_response();
470                }
471            }
472
473            // #850 — usurpation d'identité ou de lot : 403, pas 400. Le préfixe
474            // `FORBIDDEN` posé côté cas d'usage est reconnu par `est_interdit`
475            // (déjà le lexique bilingue partagé par les autres gestionnaires).
476            if est_interdit(&err) {
477                return HttpResponse::Forbidden().json(serde_json::json!({"error": err}));
478            }
479
480            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
481        }
482    }
483}
484
485#[utoipa::path(
486    get,
487    path = "/resolutions/{resolution_id}/votes",
488    tag = "Resolutions",
489    summary = "List all votes for a resolution",
490    params(
491        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
492    ),
493    responses(
494        (status = 200, description = "List of votes"),
495        (status = 500, description = "Internal Server Error"),
496    ),
497    security(("bearer_auth" = []))
498)]
499#[get("/resolutions/{resolution_id}/votes")]
500pub async fn list_resolution_votes(
501    state: web::Data<AppState>,
502    user: AuthenticatedUser,
503    resolution_id: web::Path<Uuid>,
504) -> impl Responder {
505    // On remonte à l'AG par la résolution, puis on applique le même mandat.
506    let Ok(Some(resolution)) = state
507        .resolution_use_cases
508        .get_resolution(*resolution_id)
509        .await
510    else {
511        return HttpResponse::NotFound().json(serde_json::json!({
512            "error": "Resolution not found"
513        }));
514    };
515    if let Some(refus) = verifier_mandat_sur_ag(&state, &user, resolution.meeting_id).await {
516        return refus;
517    }
518    match state
519        .resolution_use_cases
520        .get_resolution_votes(*resolution_id)
521        .await
522    {
523        Ok(votes) => {
524            let responses: Vec<VoteResponse> = votes.into_iter().map(VoteResponse::from).collect();
525            HttpResponse::Ok().json(responses)
526        }
527        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
528            "error": err
529        })),
530    }
531}
532
533#[utoipa::path(
534    put,
535    path = "/votes/{vote_id}",
536    tag = "Resolutions",
537    summary = "Change an existing vote",
538    params(
539        ("vote_id" = Uuid, Path, description = "Vote UUID")
540    ),
541    request_body = ChangeVoteRequest,
542    responses(
543        (status = 200, description = "Vote changed"),
544        (status = 400, description = "Bad Request"),
545        (status = 401, description = "Unauthorized"),
546    ),
547    security(("bearer_auth" = []))
548)]
549#[put("/votes/{vote_id}")]
550pub async fn change_vote(
551    state: web::Data<AppState>,
552    user: AuthenticatedUser,
553    vote_id: web::Path<Uuid>,
554    request: web::Json<ChangeVoteRequest>,
555) -> impl Responder {
556    let organization_id = match user.require_organization() {
557        Ok(org_id) => org_id,
558        Err(e) => {
559            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
560        }
561    };
562
563    match state
564        .resolution_use_cases
565        .change_vote(*vote_id, request.vote_choice.clone())
566        .await
567    {
568        Ok(vote) => {
569            AuditLogEntry::new(
570                AuditEventType::VoteChanged,
571                Some(user.user_id),
572                Some(organization_id),
573            )
574            .with_resource("Vote", vote.id)
575            .with_metadata(serde_json::json!({
576                "new_choice": format!("{:?}", vote.vote_choice)
577            }))
578            .log();
579
580            HttpResponse::Ok().json(VoteResponse::from(vote))
581        }
582        Err(err) => {
583            AuditLogEntry::new(
584                AuditEventType::VoteChanged,
585                Some(user.user_id),
586                Some(organization_id),
587            )
588            .with_error(err.clone())
589            .log();
590
591            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
592        }
593    }
594}
595
596#[utoipa::path(
597    put,
598    path = "/resolutions/{resolution_id}/close",
599    tag = "Resolutions",
600    summary = "Close voting on a resolution and calculate result",
601    params(
602        ("resolution_id" = Uuid, Path, description = "Resolution UUID")
603    ),
604    request_body = CloseVotingRequest,
605    responses(
606        (status = 200, description = "Voting closed and result calculated"),
607        (status = 400, description = "Bad Request"),
608        (status = 401, description = "Unauthorized"),
609    ),
610    security(("bearer_auth" = []))
611)]
612#[put("/resolutions/{resolution_id}/close")]
613pub async fn close_voting(
614    state: web::Data<AppState>,
615    user: AuthenticatedUser,
616    resolution_id: web::Path<Uuid>,
617    // Corps accepté mais inutilisé : voir le commentaire du calcul ci-dessous.
618    // On le garde dans la signature pour continuer d'accepter les appelants
619    // qui envoient encore `total_voting_power`, sans jamais s'en servir.
620    _request: web::Json<CloseVotingRequest>,
621) -> impl Responder {
622    let organization_id = match user.require_organization() {
623        Ok(org_id) => org_id,
624        Err(e) => {
625            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
626        }
627    };
628
629    // Clôturer un vote proclame une majorité opposable (Art. 3.87 §4 CC) :
630    // c'est un geste du syndic, jamais d'un copropriétaire — même membre de
631    // la même organisation. Ce handler ne vérifiait ni le rôle ni le mandat
632    // sur l'AG : n'importe quel utilisateur authentifié de l'organisation
633    // pouvait clôturer la résolution de n'importe quelle AG qu'elle gère.
634    // Relevé en écrivant le parcours multi-rôle complet du cycle d'AG (#780).
635    if let Err(err) = require_syndic_or_superadmin(&user) {
636        return err.error_response();
637    }
638
639    // Le dénominateur de la majorité est lu SUR L'IMMEUBLE, jamais reçu du
640    // client.
641    //
642    // `close_voting` le prenait dans le corps de la requête. Deux défauts d'un
643    // coup : le frontend envoyait `{}`, donc la désérialisation échouait en 400
644    // et le bouton « Clôturer le vote » paraissait inerte — rapporté trois fois
645    // en recette (R3-3, RN-8), et deuxième des trois verrous qui empêchent une
646    // AG d'aboutir (#780). Et surtout, un client qui aurait fourni un total
647    // erroné faisait proclamer une majorité qui n'existe pas.
648    //
649    // Les tantièmes de l'acte de base sont une donnée de l'immeuble. C'est là
650    // qu'on les prend.
651    let total_voting_power = {
652        let Ok(Some(resolution)) = state
653            .resolution_use_cases
654            .get_resolution(*resolution_id)
655            .await
656        else {
657            return HttpResponse::NotFound().json(serde_json::json!({
658                "error": "Resolution not found"
659            }));
660        };
661        if let Some(refus) = verifier_mandat_sur_ag(&state, &user, resolution.meeting_id).await {
662            return refus;
663        }
664        let Ok(Some(meeting)) = state
665            .meeting_use_cases
666            .get_meeting(resolution.meeting_id)
667            .await
668        else {
669            return HttpResponse::NotFound().json(serde_json::json!({
670                "error": "Meeting not found"
671            }));
672        };
673        let Ok(Some(building)) = state
674            .building_use_cases
675            .get_building(meeting.building_id)
676            .await
677        else {
678            return HttpResponse::NotFound().json(serde_json::json!({
679                "error": "Building not found"
680            }));
681        };
682        rust_decimal::Decimal::from(building.total_tantiemes)
683    };
684
685    match state
686        .resolution_use_cases
687        .close_voting(*resolution_id, total_voting_power)
688        .await
689    {
690        Ok(resolution) => {
691            AuditLogEntry::new(
692                AuditEventType::VotingClosed,
693                Some(user.user_id),
694                Some(organization_id),
695            )
696            .with_resource("Resolution", resolution.id)
697            .with_metadata(serde_json::json!({
698                "final_status": format!("{:?}", resolution.status)
699            }))
700            .log();
701
702            HttpResponse::Ok().json(ResolutionResponse::from(resolution))
703        }
704        Err(err) => {
705            AuditLogEntry::new(
706                AuditEventType::VotingClosed,
707                Some(user.user_id),
708                Some(organization_id),
709            )
710            .with_error(err.clone())
711            .log();
712
713            HttpResponse::BadRequest().json(serde_json::json!({"error": err}))
714        }
715    }
716}
717
718#[utoipa::path(
719    get,
720    path = "/meetings/{meeting_id}/vote-summary",
721    tag = "Resolutions",
722    summary = "Get vote summary for a meeting",
723    params(
724        ("meeting_id" = Uuid, Path, description = "Meeting UUID")
725    ),
726    responses(
727        (status = 200, description = "Vote summary for all meeting resolutions"),
728        (status = 500, description = "Internal Server Error"),
729    ),
730    security(("bearer_auth" = []))
731)]
732#[get("/meetings/{meeting_id}/vote-summary")]
733pub async fn get_meeting_vote_summary(
734    state: web::Data<AppState>,
735    meeting_id: web::Path<Uuid>,
736    user: AuthenticatedUser,
737) -> impl Responder {
738    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772). C'est
739    // par ce genre de route qu'un cabinet a lu les bulletins NOMINATIFS d'une
740    // autre copropriete (RN-2).
741    if let Err(err) =
742        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
743            &user,
744            *meeting_id,
745            &state.meeting_use_cases,
746            &state.building_use_cases,
747            &state.acp_use_cases,
748        )
749        .await
750    {
751        return err.error_response();
752    }
753
754    match state
755        .resolution_use_cases
756        .get_meeting_vote_summary(*meeting_id)
757        .await
758    {
759        Ok(resolutions) => {
760            let responses: Vec<ResolutionResponse> = resolutions
761                .into_iter()
762                .map(ResolutionResponse::from)
763                .collect();
764            HttpResponse::Ok().json(responses)
765        }
766        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({
767            "error": err
768        })),
769    }
770}