Skip to main content

koprogo_api/infrastructure/web/handlers/
poll_handlers.rs

1use crate::application::dto::{
2    CastVoteDto, CreatePollDto, PageRequest, PollFilters, SortOrder, UpdatePollDto,
3};
4use crate::infrastructure::web::classification_erreurs::{est_interdit, est_introuvable};
5use crate::infrastructure::web::middleware::scope_guard::{
6    verify_building_org_access, verify_poll_org_access,
7};
8use crate::infrastructure::web::middleware::AuthenticatedUser;
9use crate::infrastructure::web::AppState;
10use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse, ResponseError};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14// ============================================================================
15// Poll Management Endpoints
16// ============================================================================
17
18/// Create a new poll (draft status)
19/// POST /api/v1/polls
20#[utoipa::path(
21    post,
22    path = "/polls",
23    tag = "Polls",
24    summary = "Create a new poll",
25    request_body = CreatePollDto,
26    responses(
27        (status = 201, description = "Poll created"),
28        (status = 400, description = "Bad Request"),
29    ),
30    security(("bearer_auth" = []))
31)]
32#[post("/polls")]
33pub async fn create_poll(
34    state: web::Data<AppState>,
35    auth_user: AuthenticatedUser,
36    dto: web::Json<CreatePollDto>,
37) -> HttpResponse {
38    match state
39        .poll_use_cases
40        .create_poll(dto.into_inner(), auth_user.user_id)
41        .await
42    {
43        Ok(poll) => HttpResponse::Created().json(poll),
44        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
45            "error": e
46        })),
47    }
48}
49
50/// Get poll by ID
51/// GET /api/v1/polls/:id
52#[utoipa::path(
53    get,
54    path = "/polls/{id}",
55    tag = "Polls",
56    summary = "Get poll by ID",
57    params(
58        ("id" = String, Path, description = "Poll UUID")
59    ),
60    responses(
61        (status = 200, description = "Poll found"),
62        (status = 400, description = "Invalid ID format"),
63        (status = 404, description = "Poll not found"),
64        (status = 500, description = "Internal Server Error"),
65    ),
66    security(("bearer_auth" = []))
67)]
68#[get("/polls/{id}")]
69pub async fn get_poll(
70    state: web::Data<AppState>,
71    auth_user: AuthenticatedUser,
72    path: web::Path<String>,
73) -> HttpResponse {
74    let poll_id = match Uuid::parse_str(&path.into_inner()) {
75        Ok(id) => id,
76        Err(_) => {
77            return HttpResponse::BadRequest().json(serde_json::json!({
78                "error": "Invalid poll ID format"
79            }))
80        }
81    };
82
83    // Cloisonnement : ce sondage relève d'une ACP précise (#772).
84    if let Err(err) = verify_poll_org_access(
85        &auth_user,
86        poll_id,
87        &state.poll_use_cases,
88        &state.building_use_cases,
89        &state.acp_use_cases,
90    )
91    .await
92    {
93        return err.error_response();
94    }
95
96    match state.poll_use_cases.get_poll(poll_id).await {
97        Ok(poll) => HttpResponse::Ok().json(poll),
98        Err(e) => {
99            if est_introuvable(&e) {
100                HttpResponse::NotFound().json(serde_json::json!({
101                    "error": e
102                }))
103            } else {
104                HttpResponse::InternalServerError().json(serde_json::json!({
105                    "error": e
106                }))
107            }
108        }
109    }
110}
111
112/// Update poll (only draft polls can be updated)
113/// PUT /api/v1/polls/:id
114#[utoipa::path(
115    put,
116    path = "/polls/{id}",
117    tag = "Polls",
118    summary = "Update a draft poll",
119    params(
120        ("id" = String, Path, description = "Poll UUID")
121    ),
122    request_body = UpdatePollDto,
123    responses(
124        (status = 200, description = "Poll updated"),
125        (status = 400, description = "Bad Request"),
126        (status = 403, description = "Forbidden"),
127        (status = 404, description = "Poll not found"),
128    ),
129    security(("bearer_auth" = []))
130)]
131#[put("/polls/{id}")]
132pub async fn update_poll(
133    state: web::Data<AppState>,
134    auth_user: AuthenticatedUser,
135    path: web::Path<String>,
136    dto: web::Json<UpdatePollDto>,
137) -> HttpResponse {
138    let poll_id = match Uuid::parse_str(&path.into_inner()) {
139        Ok(id) => id,
140        Err(_) => {
141            return HttpResponse::BadRequest().json(serde_json::json!({
142                "error": "Invalid poll ID format"
143            }))
144        }
145    };
146
147    match state
148        .poll_use_cases
149        .update_poll(poll_id, dto.into_inner(), auth_user.user_id)
150        .await
151    {
152        Ok(poll) => HttpResponse::Ok().json(poll),
153        Err(e) => {
154            if est_introuvable(&e) {
155                HttpResponse::NotFound().json(serde_json::json!({
156                    "error": e
157                }))
158            } else if est_interdit(&e) {
159                HttpResponse::Forbidden().json(serde_json::json!({
160                    "error": e
161                }))
162            } else {
163                HttpResponse::BadRequest().json(serde_json::json!({
164                    "error": e
165                }))
166            }
167        }
168    }
169}
170
171/// List polls with pagination and filters
172/// GET /api/v1/polls?page=1&per_page=10&building_id=xxx&status=active
173#[utoipa::path(
174    get,
175    path = "/polls",
176    tag = "Polls",
177    summary = "List polls with pagination and filters",
178    params(
179        ("page" = Option<i64>, Query, description = "Page number"),
180        ("per_page" = Option<i64>, Query, description = "Items per page"),
181        ("building_id" = Option<String>, Query, description = "Filter by building UUID"),
182        ("created_by" = Option<String>, Query, description = "Filter by creator UUID"),
183        ("ends_before" = Option<String>, Query, description = "Filter polls ending before date"),
184        ("ends_after" = Option<String>, Query, description = "Filter polls ending after date"),
185    ),
186    responses(
187        (status = 200, description = "Paginated list of polls"),
188        (status = 500, description = "Internal Server Error"),
189    ),
190    security(("bearer_auth" = []))
191)]
192#[get("/polls")]
193pub async fn list_polls(
194    state: web::Data<AppState>,
195    auth_user: AuthenticatedUser,
196    query: web::Query<ListPollsQuery>,
197) -> HttpResponse {
198    // Cloisonnement (#882) : le commentaire précédent affirmait « c'est le cas
199    // d'usage qui filtre » — c'était FAUX. `list_polls_paginated` ne filtre
200    // que sur les paramètres fournis par le CLIENT, jamais sur l'organisation
201    // de l'appelant. Sans `building_id`, la route rendait TOUS les sondages de
202    // l'instance ; avec le `building_id` d'une autre ACP, ceux d'une
203    // copropriété tierce.
204    match &query.building_id {
205        Some(id_str) => {
206            let building_id = match Uuid::parse_str(id_str) {
207                Ok(id) => id,
208                Err(_) => {
209                    return HttpResponse::BadRequest().json(serde_json::json!({
210                        "error": "Invalid building_id format"
211                    }))
212                }
213            };
214            if let Err(err) = verify_building_org_access(
215                &auth_user,
216                building_id,
217                &state.building_use_cases,
218                &state.acp_use_cases,
219            )
220            .await
221            {
222                return err.error_response();
223            }
224        }
225        None => {
226            // Sans immeuble, la route parcourrait TOUS les sondages de
227            // l'instance : réservée au superadministrateur, comme les
228            // balayages IoT sans immeuble (#864).
229            if !auth_user.is_superadmin() {
230                return HttpResponse::Forbidden().json(serde_json::json!({
231                    "error": "building_id is required"
232                }));
233            }
234        }
235    }
236
237    let page_request = PageRequest {
238        page: query.page.unwrap_or(1),
239        per_page: query.per_page.unwrap_or(10),
240        sort_by: None,
241        order: SortOrder::Desc,
242    };
243
244    let filters = PollFilters {
245        building_id: query.building_id.clone(),
246        created_by: query.created_by.clone(),
247        status: None, // Parse from string if needed
248        poll_type: None,
249        ends_before: query.ends_before.clone(),
250        ends_after: query.ends_after.clone(),
251    };
252
253    match state
254        .poll_use_cases
255        .list_polls_paginated(&page_request, &filters)
256        .await
257    {
258        Ok(response) => HttpResponse::Ok().json(response),
259        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
260            "error": e
261        })),
262    }
263}
264
265#[derive(Debug, Deserialize)]
266pub struct ListPollsQuery {
267    pub page: Option<i64>,
268    pub per_page: Option<i64>,
269    pub building_id: Option<String>,
270    pub created_by: Option<String>,
271    pub ends_before: Option<String>,
272    pub ends_after: Option<String>,
273}
274
275/// Find active polls for a building
276/// GET /api/v1/buildings/:building_id/polls/active
277#[utoipa::path(
278    get,
279    path = "/buildings/{building_id}/polls/active",
280    tag = "Polls",
281    summary = "List active polls for a building",
282    params(
283        ("building_id" = String, Path, description = "Building UUID")
284    ),
285    responses(
286        (status = 200, description = "List of active polls"),
287        (status = 400, description = "Invalid building ID format"),
288        (status = 500, description = "Internal Server Error"),
289    ),
290    security(("bearer_auth" = []))
291)]
292#[get("/buildings/{building_id}/polls/active")]
293pub async fn find_active_polls(
294    state: web::Data<AppState>,
295    auth_user: AuthenticatedUser,
296    path: web::Path<String>,
297) -> HttpResponse {
298    let building_id = match Uuid::parse_str(&path.into_inner()) {
299        Ok(id) => id,
300        Err(_) => {
301            return HttpResponse::BadRequest().json(serde_json::json!({
302                "error": "Invalid building ID format"
303            }))
304        }
305    };
306
307    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
308    // utilisateur a le droit de voir. Un sondage dit ce que les
309    // copropriétaires pensent d'une question — le lire hors de son ACP, c'est
310    // lire une délibération qui ne vous regarde pas.
311    //
312    // L'identité était prise puis ignorée — `_auth_user` (#772).
313    if let Err(err) = verify_building_org_access(
314        &auth_user,
315        building_id,
316        &state.building_use_cases,
317        &state.acp_use_cases,
318    )
319    .await
320    {
321        return err.error_response();
322    }
323
324    match state.poll_use_cases.find_active_polls(building_id).await {
325        Ok(polls) => HttpResponse::Ok().json(polls),
326        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
327            "error": e
328        })),
329    }
330}
331
332/// Publish a poll (change from draft to active)
333/// POST /api/v1/polls/:id/publish
334#[utoipa::path(
335    post,
336    path = "/polls/{id}/publish",
337    tag = "Polls",
338    summary = "Publish a draft poll",
339    params(
340        ("id" = String, Path, description = "Poll UUID")
341    ),
342    responses(
343        (status = 200, description = "Poll published"),
344        (status = 400, description = "Bad Request"),
345        (status = 403, description = "Forbidden"),
346        (status = 404, description = "Poll not found"),
347    ),
348    security(("bearer_auth" = []))
349)]
350#[post("/polls/{id}/publish")]
351pub async fn publish_poll(
352    state: web::Data<AppState>,
353    auth_user: AuthenticatedUser,
354    path: web::Path<String>,
355) -> HttpResponse {
356    let poll_id = match Uuid::parse_str(&path.into_inner()) {
357        Ok(id) => id,
358        Err(_) => {
359            return HttpResponse::BadRequest().json(serde_json::json!({
360                "error": "Invalid poll ID format"
361            }))
362        }
363    };
364
365    // Cloisonnement : ce sondage doit relever d'une ACP que cet utilisateur a
366    // le droit de voir. Lire les résultats d'un sondage d'une autre
367    // copropriété, c'est apprendre ce que des voisins qui ne sont pas les
368    // vôtres pensent d'un sujet qui ne vous regarde pas ; le publier ou le
369    // clore depuis l'extérieur interromprait une consultation en cours (#772).
370    if let Err(err) = verify_poll_org_access(
371        &auth_user,
372        poll_id,
373        &state.poll_use_cases,
374        &state.building_use_cases,
375        &state.acp_use_cases,
376    )
377    .await
378    {
379        return err.error_response();
380    }
381
382    match state
383        .poll_use_cases
384        .publish_poll(poll_id, auth_user.user_id)
385        .await
386    {
387        Ok(poll) => HttpResponse::Ok().json(poll),
388        Err(e) => {
389            if est_introuvable(&e) {
390                HttpResponse::NotFound().json(serde_json::json!({
391                    "error": e
392                }))
393            } else if est_interdit(&e) {
394                HttpResponse::Forbidden().json(serde_json::json!({
395                    "error": e
396                }))
397            } else {
398                HttpResponse::BadRequest().json(serde_json::json!({
399                    "error": e
400                }))
401            }
402        }
403    }
404}
405
406/// Close a poll manually
407/// POST /api/v1/polls/:id/close
408#[utoipa::path(
409    post,
410    path = "/polls/{id}/close",
411    tag = "Polls",
412    summary = "Close a poll manually",
413    params(
414        ("id" = String, Path, description = "Poll UUID")
415    ),
416    responses(
417        (status = 200, description = "Poll closed"),
418        (status = 400, description = "Bad Request"),
419        (status = 403, description = "Forbidden"),
420        (status = 404, description = "Poll not found"),
421    ),
422    security(("bearer_auth" = []))
423)]
424#[post("/polls/{id}/close")]
425pub async fn close_poll(
426    state: web::Data<AppState>,
427    auth_user: AuthenticatedUser,
428    path: web::Path<String>,
429) -> HttpResponse {
430    let poll_id = match Uuid::parse_str(&path.into_inner()) {
431        Ok(id) => id,
432        Err(_) => {
433            return HttpResponse::BadRequest().json(serde_json::json!({
434                "error": "Invalid poll ID format"
435            }))
436        }
437    };
438
439    // Cloisonnement : ce sondage doit relever d'une ACP que cet utilisateur a
440    // le droit de voir. Lire les résultats d'un sondage d'une autre
441    // copropriété, c'est apprendre ce que des voisins qui ne sont pas les
442    // vôtres pensent d'un sujet qui ne vous regarde pas ; le publier ou le
443    // clore depuis l'extérieur interromprait une consultation en cours (#772).
444    if let Err(err) = verify_poll_org_access(
445        &auth_user,
446        poll_id,
447        &state.poll_use_cases,
448        &state.building_use_cases,
449        &state.acp_use_cases,
450    )
451    .await
452    {
453        return err.error_response();
454    }
455
456    match state
457        .poll_use_cases
458        .close_poll(poll_id, auth_user.user_id)
459        .await
460    {
461        Ok(poll) => HttpResponse::Ok().json(poll),
462        Err(e) => {
463            if est_introuvable(&e) {
464                HttpResponse::NotFound().json(serde_json::json!({
465                    "error": e
466                }))
467            } else if est_interdit(&e) {
468                HttpResponse::Forbidden().json(serde_json::json!({
469                    "error": e
470                }))
471            } else {
472                HttpResponse::BadRequest().json(serde_json::json!({
473                    "error": e
474                }))
475            }
476        }
477    }
478}
479
480/// Cancel a poll
481/// POST /api/v1/polls/:id/cancel
482#[utoipa::path(
483    post,
484    path = "/polls/{id}/cancel",
485    tag = "Polls",
486    summary = "Cancel a poll",
487    params(
488        ("id" = String, Path, description = "Poll UUID")
489    ),
490    responses(
491        (status = 200, description = "Poll cancelled"),
492        (status = 400, description = "Bad Request"),
493        (status = 403, description = "Forbidden"),
494        (status = 404, description = "Poll not found"),
495    ),
496    security(("bearer_auth" = []))
497)]
498#[post("/polls/{id}/cancel")]
499pub async fn cancel_poll(
500    state: web::Data<AppState>,
501    auth_user: AuthenticatedUser,
502    path: web::Path<String>,
503) -> HttpResponse {
504    let poll_id = match Uuid::parse_str(&path.into_inner()) {
505        Ok(id) => id,
506        Err(_) => {
507            return HttpResponse::BadRequest().json(serde_json::json!({
508                "error": "Invalid poll ID format"
509            }))
510        }
511    };
512
513    // Cloisonnement : ce sondage doit relever d'une ACP que cet utilisateur a
514    // le droit de voir. Lire les résultats d'un sondage d'une autre
515    // copropriété, c'est apprendre ce que des voisins qui ne sont pas les
516    // vôtres pensent d'un sujet qui ne vous regarde pas ; le publier ou le
517    // clore depuis l'extérieur interromprait une consultation en cours (#772).
518    if let Err(err) = verify_poll_org_access(
519        &auth_user,
520        poll_id,
521        &state.poll_use_cases,
522        &state.building_use_cases,
523        &state.acp_use_cases,
524    )
525    .await
526    {
527        return err.error_response();
528    }
529
530    match state
531        .poll_use_cases
532        .cancel_poll(poll_id, auth_user.user_id)
533        .await
534    {
535        Ok(poll) => HttpResponse::Ok().json(poll),
536        Err(e) => {
537            if est_introuvable(&e) {
538                HttpResponse::NotFound().json(serde_json::json!({
539                    "error": e
540                }))
541            } else if est_interdit(&e) {
542                HttpResponse::Forbidden().json(serde_json::json!({
543                    "error": e
544                }))
545            } else {
546                HttpResponse::BadRequest().json(serde_json::json!({
547                    "error": e
548                }))
549            }
550        }
551    }
552}
553
554/// Delete a poll (only draft or cancelled)
555/// DELETE /api/v1/polls/:id
556#[utoipa::path(
557    delete,
558    path = "/polls/{id}",
559    tag = "Polls",
560    summary = "Delete a draft or cancelled poll",
561    params(
562        ("id" = String, Path, description = "Poll UUID")
563    ),
564    responses(
565        (status = 204, description = "Poll deleted"),
566        (status = 400, description = "Bad Request"),
567        (status = 403, description = "Forbidden"),
568        (status = 404, description = "Poll not found"),
569    ),
570    security(("bearer_auth" = []))
571)]
572#[delete("/polls/{id}")]
573pub async fn delete_poll(
574    state: web::Data<AppState>,
575    auth_user: AuthenticatedUser,
576    path: web::Path<String>,
577) -> HttpResponse {
578    let poll_id = match Uuid::parse_str(&path.into_inner()) {
579        Ok(id) => id,
580        Err(_) => {
581            return HttpResponse::BadRequest().json(serde_json::json!({
582                "error": "Invalid poll ID format"
583            }))
584        }
585    };
586
587    match state
588        .poll_use_cases
589        .delete_poll(poll_id, auth_user.user_id)
590        .await
591    {
592        Ok(true) => HttpResponse::NoContent().finish(),
593        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
594            "error": "Poll not found"
595        })),
596        Err(e) => {
597            if est_interdit(&e) {
598                HttpResponse::Forbidden().json(serde_json::json!({
599                    "error": e
600                }))
601            } else {
602                HttpResponse::BadRequest().json(serde_json::json!({
603                    "error": e
604                }))
605            }
606        }
607    }
608}
609
610// ============================================================================
611// Voting Endpoints
612// ============================================================================
613
614/// Cast a vote on a poll
615/// POST /api/v1/polls/vote
616#[utoipa::path(
617    post,
618    path = "/polls/vote",
619    tag = "Polls",
620    summary = "Cast a vote on a poll",
621    request_body = CastVoteDto,
622    responses(
623        (status = 201, description = "Vote cast successfully"),
624        (status = 400, description = "Bad Request"),
625        (status = 404, description = "Poll not found"),
626        (status = 409, description = "Already voted"),
627    ),
628    security(("bearer_auth" = []))
629)]
630#[post("/polls/vote")]
631pub async fn cast_poll_vote(
632    state: web::Data<AppState>,
633    auth_user: AuthenticatedUser,
634    dto: web::Json<CastVoteDto>,
635    _req: HttpRequest,
636) -> HttpResponse {
637    // On vote en tant que COPROPRIÉTAIRE, pas en tant qu'utilisateur.
638    //
639    // Cette ligne passait `auth_user.user_id`. Le commentaire l'avouait — « for
640    // now, we use the authenticated user's ID » — et le provisoire n'a jamais
641    // été remplacé. `cast_vote` compare ensuite cette valeur aux `owner_id`
642    // que rend `find_active_by_building`, qui sont des `owners.id`.
643    //
644    // Deux entités distinctes, deux UUID différents : la comparaison ne
645    // pouvait JAMAIS être vraie. Tout copropriétaire recevait « You are not
646    // authorized to vote on this poll ». La consultation communautaire était
647    // donc écrite, testée, et inatteignable par les seules personnes à qui
648    // elle s'adresse.
649    //
650    // `find_owner_by_user_id` existait déjà et n'était appelé par aucun
651    // handler.
652    let owner_id = match state
653        .owner_use_cases
654        .find_owner_by_user_id(auth_user.user_id)
655        .await
656    {
657        // `OwnerResponseDto.id` est une `String` : la conversion doit être
658        // explicite, et son échec dit ce qui ne va pas plutôt que de rendre
659        // « non autorisé ».
660        Ok(Some(owner)) => match Uuid::parse_str(&owner.id) {
661            Ok(id) => Some(id),
662            Err(e) => {
663                return HttpResponse::InternalServerError().json(serde_json::json!({
664                    "error": format!("Identifiant de copropriétaire illisible : {}", e)
665                }));
666            }
667        },
668        Ok(None) => {
669            // Un utilisateur sans fiche de copropriétaire n'est pas un
670            // copropriétaire : le dire, plutôt que de le laisser buter sur une
671            // autorisation qui ne le nommera pas.
672            //
673            // Story 5.3 (#587), INV-4 — c'est ICI, avant tout appel à
674            // `cast_vote`, que le syndic pur (sans lot) est bloqué : voir la
675            // doc de `PollUseCases::cast_vote` pour pourquoi ce refus ne peut
676            // pas vivre dans le use case lui-même (double sens de `None`,
677            // partagé avec le vote anonyme du Scénario 8 `polls.feature`).
678            return HttpResponse::Forbidden().json(serde_json::json!({
679                "error": crate::application::error::REFUS_VOTE_RESERVE_AUX_COPROPRIETAIRES,
680                "kind": "owner_not_linked"
681            }));
682        }
683        Err(e) => {
684            return HttpResponse::InternalServerError().json(serde_json::json!({
685                "error": format!("Failed to resolve owner for user: {}", e)
686            }));
687        }
688    };
689
690    match state
691        .poll_use_cases
692        .cast_vote(dto.into_inner(), owner_id)
693        .await
694    {
695        Ok(message) => HttpResponse::Created().json(serde_json::json!({
696            "message": message
697        })),
698        Err(e) => {
699            if e.contains("not active") || e.contains("expired") {
700                HttpResponse::BadRequest().json(serde_json::json!({
701                    "error": e
702                }))
703            } else if e.contains("already voted") {
704                HttpResponse::Conflict().json(serde_json::json!({
705                    "error": e
706                }))
707            } else if est_introuvable(&e) {
708                HttpResponse::NotFound().json(serde_json::json!({
709                    "error": e
710                }))
711            } else {
712                HttpResponse::BadRequest().json(serde_json::json!({
713                    "error": e
714                }))
715            }
716        }
717    }
718}
719
720/// Get poll results
721/// GET /api/v1/polls/:id/results
722#[utoipa::path(
723    get,
724    path = "/polls/{id}/results",
725    tag = "Polls",
726    summary = "Get poll results and statistics",
727    params(
728        ("id" = String, Path, description = "Poll UUID")
729    ),
730    responses(
731        (status = 200, description = "Poll results"),
732        (status = 400, description = "Invalid ID format"),
733        (status = 404, description = "Poll not found"),
734        (status = 500, description = "Internal Server Error"),
735    ),
736    security(("bearer_auth" = []))
737)]
738#[get("/polls/{id}/results")]
739pub async fn get_poll_results(
740    state: web::Data<AppState>,
741    auth_user: AuthenticatedUser,
742    path: web::Path<String>,
743) -> HttpResponse {
744    let poll_id = match Uuid::parse_str(&path.into_inner()) {
745        Ok(id) => id,
746        Err(_) => {
747            return HttpResponse::BadRequest().json(serde_json::json!({
748                "error": "Invalid poll ID format"
749            }))
750        }
751    };
752
753    // Cloisonnement : ce sondage doit relever d'une ACP que cet utilisateur a
754    // le droit de voir. Lire les résultats d'un sondage d'une autre
755    // copropriété, c'est apprendre ce que des voisins qui ne sont pas les
756    // vôtres pensent d'un sujet qui ne vous regarde pas ; le publier ou le
757    // clore depuis l'extérieur interromprait une consultation en cours (#772).
758    if let Err(err) = verify_poll_org_access(
759        &auth_user,
760        poll_id,
761        &state.poll_use_cases,
762        &state.building_use_cases,
763        &state.acp_use_cases,
764    )
765    .await
766    {
767        return err.error_response();
768    }
769
770    match state.poll_use_cases.get_poll_results(poll_id).await {
771        Ok(results) => HttpResponse::Ok().json(results),
772        Err(e) => {
773            if est_introuvable(&e) {
774                HttpResponse::NotFound().json(serde_json::json!({
775                    "error": e
776                }))
777            } else {
778                HttpResponse::InternalServerError().json(serde_json::json!({
779                    "error": e
780                }))
781            }
782        }
783    }
784}
785
786// ============================================================================
787// Statistics Endpoints
788// ============================================================================
789
790/// Get poll statistics for a building
791/// GET /api/v1/buildings/:building_id/polls/statistics
792#[utoipa::path(
793    get,
794    path = "/buildings/{building_id}/polls/statistics",
795    tag = "Polls",
796    summary = "Get poll statistics for a building",
797    params(
798        ("building_id" = String, Path, description = "Building UUID")
799    ),
800    responses(
801        (status = 200, description = "Poll statistics"),
802        (status = 400, description = "Invalid building ID format"),
803        (status = 500, description = "Internal Server Error"),
804    ),
805    security(("bearer_auth" = []))
806)]
807#[get("/buildings/{building_id}/polls/statistics")]
808pub async fn get_poll_building_statistics(
809    state: web::Data<AppState>,
810    auth_user: AuthenticatedUser,
811    path: web::Path<String>,
812) -> HttpResponse {
813    let building_id = match Uuid::parse_str(&path.into_inner()) {
814        Ok(id) => id,
815        Err(_) => {
816            return HttpResponse::BadRequest().json(serde_json::json!({
817                "error": "Invalid building ID format"
818            }))
819        }
820    };
821
822    // Cloisonnement : l'immeuble visé doit relever d'une ACP que cet
823    // utilisateur a le droit de voir. Les statistiques de sondage disent ce
824    // que les copropriétaires ont répondu, en agrégé — cela reste une
825    // délibération d'ACP (#772).
826    if let Err(err) = verify_building_org_access(
827        &auth_user,
828        building_id,
829        &state.building_use_cases,
830        &state.acp_use_cases,
831    )
832    .await
833    {
834        return err.error_response();
835    }
836
837    match state
838        .poll_use_cases
839        .get_building_statistics(building_id)
840        .await
841    {
842        Ok(stats) => HttpResponse::Ok().json(stats),
843        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
844            "error": e
845        })),
846    }
847}
848
849// ============================================================================
850// Statistics Response DTO
851// ============================================================================
852
853#[derive(Debug, Serialize)]
854pub struct PollStatisticsResponse {
855    pub total_polls: i64,
856    pub active_polls: i64,
857    pub closed_polls: i64,
858    pub average_participation_rate: f64,
859}