koprogo_api/infrastructure/web/handlers/
poll_handlers.rs

1use crate::application::dto::{
2    CastVoteDto, CreatePollDto, PageRequest, PollFilters, SortOrder, UpdatePollDto,
3};
4use crate::infrastructure::web::middleware::AuthenticatedUser;
5use crate::infrastructure::web::AppState;
6use actix_web::{delete, get, post, put, web, HttpRequest, HttpResponse};
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10// ============================================================================
11// Poll Management Endpoints
12// ============================================================================
13
14/// Create a new poll (draft status)
15/// POST /api/v1/polls
16#[utoipa::path(
17    post,
18    path = "/polls",
19    tag = "Polls",
20    summary = "Create a new poll",
21    request_body = CreatePollDto,
22    responses(
23        (status = 201, description = "Poll created"),
24        (status = 400, description = "Bad Request"),
25    ),
26    security(("bearer_auth" = []))
27)]
28#[post("/polls")]
29pub async fn create_poll(
30    state: web::Data<AppState>,
31    auth_user: AuthenticatedUser,
32    dto: web::Json<CreatePollDto>,
33) -> HttpResponse {
34    match state
35        .poll_use_cases
36        .create_poll(dto.into_inner(), auth_user.user_id)
37        .await
38    {
39        Ok(poll) => HttpResponse::Created().json(poll),
40        Err(e) => HttpResponse::BadRequest().json(serde_json::json!({
41            "error": e
42        })),
43    }
44}
45
46/// Get poll by ID
47/// GET /api/v1/polls/:id
48#[utoipa::path(
49    get,
50    path = "/polls/{id}",
51    tag = "Polls",
52    summary = "Get poll by ID",
53    params(
54        ("id" = String, Path, description = "Poll UUID")
55    ),
56    responses(
57        (status = 200, description = "Poll found"),
58        (status = 400, description = "Invalid ID format"),
59        (status = 404, description = "Poll not found"),
60        (status = 500, description = "Internal Server Error"),
61    ),
62    security(("bearer_auth" = []))
63)]
64#[get("/polls/{id}")]
65pub async fn get_poll(
66    state: web::Data<AppState>,
67    _auth_user: AuthenticatedUser,
68    path: web::Path<String>,
69) -> HttpResponse {
70    let poll_id = match Uuid::parse_str(&path.into_inner()) {
71        Ok(id) => id,
72        Err(_) => {
73            return HttpResponse::BadRequest().json(serde_json::json!({
74                "error": "Invalid poll ID format"
75            }))
76        }
77    };
78
79    match state.poll_use_cases.get_poll(poll_id).await {
80        Ok(poll) => HttpResponse::Ok().json(poll),
81        Err(e) => {
82            if e.contains("not found") {
83                HttpResponse::NotFound().json(serde_json::json!({
84                    "error": e
85                }))
86            } else {
87                HttpResponse::InternalServerError().json(serde_json::json!({
88                    "error": e
89                }))
90            }
91        }
92    }
93}
94
95/// Update poll (only draft polls can be updated)
96/// PUT /api/v1/polls/:id
97#[utoipa::path(
98    put,
99    path = "/polls/{id}",
100    tag = "Polls",
101    summary = "Update a draft poll",
102    params(
103        ("id" = String, Path, description = "Poll UUID")
104    ),
105    request_body = UpdatePollDto,
106    responses(
107        (status = 200, description = "Poll updated"),
108        (status = 400, description = "Bad Request"),
109        (status = 403, description = "Forbidden"),
110        (status = 404, description = "Poll not found"),
111    ),
112    security(("bearer_auth" = []))
113)]
114#[put("/polls/{id}")]
115pub async fn update_poll(
116    state: web::Data<AppState>,
117    auth_user: AuthenticatedUser,
118    path: web::Path<String>,
119    dto: web::Json<UpdatePollDto>,
120) -> HttpResponse {
121    let poll_id = match Uuid::parse_str(&path.into_inner()) {
122        Ok(id) => id,
123        Err(_) => {
124            return HttpResponse::BadRequest().json(serde_json::json!({
125                "error": "Invalid poll ID format"
126            }))
127        }
128    };
129
130    match state
131        .poll_use_cases
132        .update_poll(poll_id, dto.into_inner(), auth_user.user_id)
133        .await
134    {
135        Ok(poll) => HttpResponse::Ok().json(poll),
136        Err(e) => {
137            if e.contains("not found") {
138                HttpResponse::NotFound().json(serde_json::json!({
139                    "error": e
140                }))
141            } else if e.contains("Only the poll creator") {
142                HttpResponse::Forbidden().json(serde_json::json!({
143                    "error": e
144                }))
145            } else {
146                HttpResponse::BadRequest().json(serde_json::json!({
147                    "error": e
148                }))
149            }
150        }
151    }
152}
153
154/// List polls with pagination and filters
155/// GET /api/v1/polls?page=1&per_page=10&building_id=xxx&status=active
156#[utoipa::path(
157    get,
158    path = "/polls",
159    tag = "Polls",
160    summary = "List polls with pagination and filters",
161    params(
162        ("page" = Option<i64>, Query, description = "Page number"),
163        ("per_page" = Option<i64>, Query, description = "Items per page"),
164        ("building_id" = Option<String>, Query, description = "Filter by building UUID"),
165        ("created_by" = Option<String>, Query, description = "Filter by creator UUID"),
166        ("ends_before" = Option<String>, Query, description = "Filter polls ending before date"),
167        ("ends_after" = Option<String>, Query, description = "Filter polls ending after date"),
168    ),
169    responses(
170        (status = 200, description = "Paginated list of polls"),
171        (status = 500, description = "Internal Server Error"),
172    ),
173    security(("bearer_auth" = []))
174)]
175#[get("/polls")]
176pub async fn list_polls(
177    state: web::Data<AppState>,
178    _auth_user: AuthenticatedUser,
179    query: web::Query<ListPollsQuery>,
180) -> HttpResponse {
181    let page_request = PageRequest {
182        page: query.page.unwrap_or(1),
183        per_page: query.per_page.unwrap_or(10),
184        sort_by: None,
185        order: SortOrder::Desc,
186    };
187
188    let filters = PollFilters {
189        building_id: query.building_id.clone(),
190        created_by: query.created_by.clone(),
191        status: None, // Parse from string if needed
192        poll_type: None,
193        ends_before: query.ends_before.clone(),
194        ends_after: query.ends_after.clone(),
195    };
196
197    match state
198        .poll_use_cases
199        .list_polls_paginated(&page_request, &filters)
200        .await
201    {
202        Ok(response) => HttpResponse::Ok().json(response),
203        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
204            "error": e
205        })),
206    }
207}
208
209#[derive(Debug, Deserialize)]
210pub struct ListPollsQuery {
211    pub page: Option<i64>,
212    pub per_page: Option<i64>,
213    pub building_id: Option<String>,
214    pub created_by: Option<String>,
215    pub ends_before: Option<String>,
216    pub ends_after: Option<String>,
217}
218
219/// Find active polls for a building
220/// GET /api/v1/buildings/:building_id/polls/active
221#[utoipa::path(
222    get,
223    path = "/buildings/{building_id}/polls/active",
224    tag = "Polls",
225    summary = "List active polls for a building",
226    params(
227        ("building_id" = String, Path, description = "Building UUID")
228    ),
229    responses(
230        (status = 200, description = "List of active polls"),
231        (status = 400, description = "Invalid building ID format"),
232        (status = 500, description = "Internal Server Error"),
233    ),
234    security(("bearer_auth" = []))
235)]
236#[get("/buildings/{building_id}/polls/active")]
237pub async fn find_active_polls(
238    state: web::Data<AppState>,
239    _auth_user: AuthenticatedUser,
240    path: web::Path<String>,
241) -> HttpResponse {
242    let building_id = match Uuid::parse_str(&path.into_inner()) {
243        Ok(id) => id,
244        Err(_) => {
245            return HttpResponse::BadRequest().json(serde_json::json!({
246                "error": "Invalid building ID format"
247            }))
248        }
249    };
250
251    match state.poll_use_cases.find_active_polls(building_id).await {
252        Ok(polls) => HttpResponse::Ok().json(polls),
253        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
254            "error": e
255        })),
256    }
257}
258
259/// Publish a poll (change from draft to active)
260/// POST /api/v1/polls/:id/publish
261#[utoipa::path(
262    post,
263    path = "/polls/{id}/publish",
264    tag = "Polls",
265    summary = "Publish a draft poll",
266    params(
267        ("id" = String, Path, description = "Poll UUID")
268    ),
269    responses(
270        (status = 200, description = "Poll published"),
271        (status = 400, description = "Bad Request"),
272        (status = 403, description = "Forbidden"),
273        (status = 404, description = "Poll not found"),
274    ),
275    security(("bearer_auth" = []))
276)]
277#[post("/polls/{id}/publish")]
278pub async fn publish_poll(
279    state: web::Data<AppState>,
280    auth_user: AuthenticatedUser,
281    path: web::Path<String>,
282) -> HttpResponse {
283    let poll_id = match Uuid::parse_str(&path.into_inner()) {
284        Ok(id) => id,
285        Err(_) => {
286            return HttpResponse::BadRequest().json(serde_json::json!({
287                "error": "Invalid poll ID format"
288            }))
289        }
290    };
291
292    match state
293        .poll_use_cases
294        .publish_poll(poll_id, auth_user.user_id)
295        .await
296    {
297        Ok(poll) => HttpResponse::Ok().json(poll),
298        Err(e) => {
299            if e.contains("not found") {
300                HttpResponse::NotFound().json(serde_json::json!({
301                    "error": e
302                }))
303            } else if e.contains("Only the poll creator") {
304                HttpResponse::Forbidden().json(serde_json::json!({
305                    "error": e
306                }))
307            } else {
308                HttpResponse::BadRequest().json(serde_json::json!({
309                    "error": e
310                }))
311            }
312        }
313    }
314}
315
316/// Close a poll manually
317/// POST /api/v1/polls/:id/close
318#[utoipa::path(
319    post,
320    path = "/polls/{id}/close",
321    tag = "Polls",
322    summary = "Close a poll manually",
323    params(
324        ("id" = String, Path, description = "Poll UUID")
325    ),
326    responses(
327        (status = 200, description = "Poll closed"),
328        (status = 400, description = "Bad Request"),
329        (status = 403, description = "Forbidden"),
330        (status = 404, description = "Poll not found"),
331    ),
332    security(("bearer_auth" = []))
333)]
334#[post("/polls/{id}/close")]
335pub async fn close_poll(
336    state: web::Data<AppState>,
337    auth_user: AuthenticatedUser,
338    path: web::Path<String>,
339) -> HttpResponse {
340    let poll_id = match Uuid::parse_str(&path.into_inner()) {
341        Ok(id) => id,
342        Err(_) => {
343            return HttpResponse::BadRequest().json(serde_json::json!({
344                "error": "Invalid poll ID format"
345            }))
346        }
347    };
348
349    match state
350        .poll_use_cases
351        .close_poll(poll_id, auth_user.user_id)
352        .await
353    {
354        Ok(poll) => HttpResponse::Ok().json(poll),
355        Err(e) => {
356            if e.contains("not found") {
357                HttpResponse::NotFound().json(serde_json::json!({
358                    "error": e
359                }))
360            } else if e.contains("Only the poll creator") {
361                HttpResponse::Forbidden().json(serde_json::json!({
362                    "error": e
363                }))
364            } else {
365                HttpResponse::BadRequest().json(serde_json::json!({
366                    "error": e
367                }))
368            }
369        }
370    }
371}
372
373/// Cancel a poll
374/// POST /api/v1/polls/:id/cancel
375#[utoipa::path(
376    post,
377    path = "/polls/{id}/cancel",
378    tag = "Polls",
379    summary = "Cancel a poll",
380    params(
381        ("id" = String, Path, description = "Poll UUID")
382    ),
383    responses(
384        (status = 200, description = "Poll cancelled"),
385        (status = 400, description = "Bad Request"),
386        (status = 403, description = "Forbidden"),
387        (status = 404, description = "Poll not found"),
388    ),
389    security(("bearer_auth" = []))
390)]
391#[post("/polls/{id}/cancel")]
392pub async fn cancel_poll(
393    state: web::Data<AppState>,
394    auth_user: AuthenticatedUser,
395    path: web::Path<String>,
396) -> HttpResponse {
397    let poll_id = match Uuid::parse_str(&path.into_inner()) {
398        Ok(id) => id,
399        Err(_) => {
400            return HttpResponse::BadRequest().json(serde_json::json!({
401                "error": "Invalid poll ID format"
402            }))
403        }
404    };
405
406    match state
407        .poll_use_cases
408        .cancel_poll(poll_id, auth_user.user_id)
409        .await
410    {
411        Ok(poll) => HttpResponse::Ok().json(poll),
412        Err(e) => {
413            if e.contains("not found") {
414                HttpResponse::NotFound().json(serde_json::json!({
415                    "error": e
416                }))
417            } else if e.contains("Only the poll creator") {
418                HttpResponse::Forbidden().json(serde_json::json!({
419                    "error": e
420                }))
421            } else {
422                HttpResponse::BadRequest().json(serde_json::json!({
423                    "error": e
424                }))
425            }
426        }
427    }
428}
429
430/// Delete a poll (only draft or cancelled)
431/// DELETE /api/v1/polls/:id
432#[utoipa::path(
433    delete,
434    path = "/polls/{id}",
435    tag = "Polls",
436    summary = "Delete a draft or cancelled poll",
437    params(
438        ("id" = String, Path, description = "Poll UUID")
439    ),
440    responses(
441        (status = 204, description = "Poll deleted"),
442        (status = 400, description = "Bad Request"),
443        (status = 403, description = "Forbidden"),
444        (status = 404, description = "Poll not found"),
445    ),
446    security(("bearer_auth" = []))
447)]
448#[delete("/polls/{id}")]
449pub async fn delete_poll(
450    state: web::Data<AppState>,
451    auth_user: AuthenticatedUser,
452    path: web::Path<String>,
453) -> HttpResponse {
454    let poll_id = match Uuid::parse_str(&path.into_inner()) {
455        Ok(id) => id,
456        Err(_) => {
457            return HttpResponse::BadRequest().json(serde_json::json!({
458                "error": "Invalid poll ID format"
459            }))
460        }
461    };
462
463    match state
464        .poll_use_cases
465        .delete_poll(poll_id, auth_user.user_id)
466        .await
467    {
468        Ok(true) => HttpResponse::NoContent().finish(),
469        Ok(false) => HttpResponse::NotFound().json(serde_json::json!({
470            "error": "Poll not found"
471        })),
472        Err(e) => {
473            if e.contains("Only the poll creator") {
474                HttpResponse::Forbidden().json(serde_json::json!({
475                    "error": e
476                }))
477            } else {
478                HttpResponse::BadRequest().json(serde_json::json!({
479                    "error": e
480                }))
481            }
482        }
483    }
484}
485
486// ============================================================================
487// Voting Endpoints
488// ============================================================================
489
490/// Cast a vote on a poll
491/// POST /api/v1/polls/vote
492#[utoipa::path(
493    post,
494    path = "/polls/vote",
495    tag = "Polls",
496    summary = "Cast a vote on a poll",
497    request_body = CastVoteDto,
498    responses(
499        (status = 201, description = "Vote cast successfully"),
500        (status = 400, description = "Bad Request"),
501        (status = 404, description = "Poll not found"),
502        (status = 409, description = "Already voted"),
503    ),
504    security(("bearer_auth" = []))
505)]
506#[post("/polls/vote")]
507pub async fn cast_poll_vote(
508    state: web::Data<AppState>,
509    auth_user: AuthenticatedUser,
510    dto: web::Json<CastVoteDto>,
511    _req: HttpRequest,
512) -> HttpResponse {
513    // Owner ID is optional (anonymous votes)
514    // For now, we use the authenticated user's ID
515    // In production, you'd have logic to determine if vote is anonymous
516    let owner_id = Some(auth_user.user_id);
517
518    match state
519        .poll_use_cases
520        .cast_vote(dto.into_inner(), owner_id)
521        .await
522    {
523        Ok(message) => HttpResponse::Created().json(serde_json::json!({
524            "message": message
525        })),
526        Err(e) => {
527            if e.contains("not active") || e.contains("expired") {
528                HttpResponse::BadRequest().json(serde_json::json!({
529                    "error": e
530                }))
531            } else if e.contains("already voted") {
532                HttpResponse::Conflict().json(serde_json::json!({
533                    "error": e
534                }))
535            } else if e.contains("not found") {
536                HttpResponse::NotFound().json(serde_json::json!({
537                    "error": e
538                }))
539            } else {
540                HttpResponse::BadRequest().json(serde_json::json!({
541                    "error": e
542                }))
543            }
544        }
545    }
546}
547
548/// Get poll results
549/// GET /api/v1/polls/:id/results
550#[utoipa::path(
551    get,
552    path = "/polls/{id}/results",
553    tag = "Polls",
554    summary = "Get poll results and statistics",
555    params(
556        ("id" = String, Path, description = "Poll UUID")
557    ),
558    responses(
559        (status = 200, description = "Poll results"),
560        (status = 400, description = "Invalid ID format"),
561        (status = 404, description = "Poll not found"),
562        (status = 500, description = "Internal Server Error"),
563    ),
564    security(("bearer_auth" = []))
565)]
566#[get("/polls/{id}/results")]
567pub async fn get_poll_results(
568    state: web::Data<AppState>,
569    _auth_user: AuthenticatedUser,
570    path: web::Path<String>,
571) -> HttpResponse {
572    let poll_id = match Uuid::parse_str(&path.into_inner()) {
573        Ok(id) => id,
574        Err(_) => {
575            return HttpResponse::BadRequest().json(serde_json::json!({
576                "error": "Invalid poll ID format"
577            }))
578        }
579    };
580
581    match state.poll_use_cases.get_poll_results(poll_id).await {
582        Ok(results) => HttpResponse::Ok().json(results),
583        Err(e) => {
584            if e.contains("not found") {
585                HttpResponse::NotFound().json(serde_json::json!({
586                    "error": e
587                }))
588            } else {
589                HttpResponse::InternalServerError().json(serde_json::json!({
590                    "error": e
591                }))
592            }
593        }
594    }
595}
596
597// ============================================================================
598// Statistics Endpoints
599// ============================================================================
600
601/// Get poll statistics for a building
602/// GET /api/v1/buildings/:building_id/polls/statistics
603#[utoipa::path(
604    get,
605    path = "/buildings/{building_id}/polls/statistics",
606    tag = "Polls",
607    summary = "Get poll statistics for a building",
608    params(
609        ("building_id" = String, Path, description = "Building UUID")
610    ),
611    responses(
612        (status = 200, description = "Poll statistics"),
613        (status = 400, description = "Invalid building ID format"),
614        (status = 500, description = "Internal Server Error"),
615    ),
616    security(("bearer_auth" = []))
617)]
618#[get("/buildings/{building_id}/polls/statistics")]
619pub async fn get_poll_building_statistics(
620    state: web::Data<AppState>,
621    _auth_user: AuthenticatedUser,
622    path: web::Path<String>,
623) -> HttpResponse {
624    let building_id = match Uuid::parse_str(&path.into_inner()) {
625        Ok(id) => id,
626        Err(_) => {
627            return HttpResponse::BadRequest().json(serde_json::json!({
628                "error": "Invalid building ID format"
629            }))
630        }
631    };
632
633    match state
634        .poll_use_cases
635        .get_building_statistics(building_id)
636        .await
637    {
638        Ok(stats) => HttpResponse::Ok().json(stats),
639        Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({
640            "error": e
641        })),
642    }
643}
644
645// ============================================================================
646// Statistics Response DTO
647// ============================================================================
648
649#[derive(Debug, Serialize)]
650pub struct PollStatisticsResponse {
651    pub total_polls: i64,
652    pub active_polls: i64,
653    pub closed_polls: i64,
654    pub average_participation_rate: f64,
655}