koprogo_api/application/dto/
poll_dto.rs

1use crate::domain::entities::{Poll, PollOption, PollStatus, PollType, PollVote};
2use chrono::Utc;
3use serde::{Deserialize, Serialize};
4use validator::Validate;
5
6/// Create a new poll
7#[derive(Debug, Deserialize, Validate, Clone)]
8pub struct CreatePollDto {
9    pub building_id: String,
10
11    #[validate(length(min = 1, max = 255))]
12    pub title: String,
13
14    pub description: Option<String>,
15    pub poll_type: String, // "yes_no", "multiple_choice", "rating", "open_ended"
16    pub options: Vec<CreatePollOptionDto>,
17    pub is_anonymous: Option<bool>,
18    pub allow_multiple_votes: Option<bool>,
19    pub require_all_owners: Option<bool>,
20    pub ends_at: String, // ISO 8601 format
21}
22
23#[derive(Debug, Deserialize, Serialize, Clone)]
24pub struct CreatePollOptionDto {
25    #[serde(default)]
26    pub id: Option<String>, // Optional UUID, will be generated if not provided
27    pub option_text: String,
28    pub attachment_url: Option<String>,
29    pub display_order: i32,
30}
31
32/// Update poll (only draft polls can be updated)
33#[derive(Debug, Deserialize, Validate, Clone)]
34pub struct UpdatePollDto {
35    #[validate(length(min = 1, max = 255))]
36    pub title: Option<String>,
37
38    pub description: Option<String>,
39    pub options: Option<Vec<CreatePollOptionDto>>,
40    pub is_anonymous: Option<bool>,
41    pub allow_multiple_votes: Option<bool>,
42    pub require_all_owners: Option<bool>,
43    pub ends_at: Option<String>,
44}
45
46/// Cast a vote on a poll
47#[derive(Debug, Deserialize, Validate, Clone)]
48pub struct CastVoteDto {
49    pub poll_id: String,
50
51    // Only one of these should be populated based on poll_type
52    pub selected_option_ids: Option<Vec<String>>, // For YesNo/MultipleChoice
53    pub rating_value: Option<i32>,                // For Rating (1-5)
54    pub open_text: Option<String>,                // For OpenEnded
55}
56
57/// Poll response DTO
58#[derive(Debug, Serialize)]
59pub struct PollResponseDto {
60    pub id: String,
61    pub building_id: String,
62    pub created_by: String,
63    pub title: String,
64    pub description: Option<String>,
65    pub poll_type: PollType,
66    pub options: Vec<PollOptionDto>,
67    pub is_anonymous: bool,
68    pub allow_multiple_votes: bool,
69    pub require_all_owners: bool,
70    pub starts_at: String,
71    pub ends_at: String,
72    pub status: PollStatus,
73    pub total_eligible_voters: i32,
74    pub total_votes_cast: i32,
75    pub participation_rate: f64,
76    pub is_active: bool,
77    pub is_ended: bool,
78    pub winning_option: Option<PollOptionDto>,
79    pub created_at: String,
80    pub updated_at: String,
81}
82
83#[derive(Debug, Serialize, Clone)]
84pub struct PollOptionDto {
85    pub id: String,
86    pub option_text: String,
87    pub attachment_url: Option<String>,
88    pub vote_count: i32,
89    pub vote_percentage: f64,
90    pub display_order: i32,
91}
92
93impl From<&PollOption> for PollOptionDto {
94    fn from(option: &PollOption) -> Self {
95        Self {
96            id: option.id.to_string(),
97            option_text: option.option_text.clone(),
98            attachment_url: option.attachment_url.clone(),
99            vote_count: option.vote_count,
100            vote_percentage: 0.0, // Will be calculated in use case
101            display_order: option.display_order,
102        }
103    }
104}
105
106/// Poll vote response DTO
107#[derive(Debug, Serialize)]
108pub struct PollVoteResponseDto {
109    pub id: String,
110    pub poll_id: String,
111    pub owner_id: Option<String>,
112    pub building_id: String,
113    pub selected_option_ids: Vec<String>,
114    pub rating_value: Option<i32>,
115    pub open_text: Option<String>,
116    pub voted_at: String,
117}
118
119/// Poll list response with pagination
120#[derive(Debug, Serialize)]
121pub struct PollListResponseDto {
122    pub polls: Vec<PollResponseDto>,
123    pub total: i64,
124    pub page: i64,
125    pub page_size: i64,
126}
127
128/// Poll results summary
129#[derive(Debug, Serialize)]
130pub struct PollResultsDto {
131    pub poll_id: String,
132    pub total_votes_cast: i32,
133    pub total_eligible_voters: i32,
134    pub participation_rate: f64,
135    pub options: Vec<PollOptionDto>,
136    pub winning_option: Option<PollOptionDto>,
137}
138
139/// Poll filters for queries
140#[derive(Debug, Deserialize, Default, Clone)]
141pub struct PollFilters {
142    pub building_id: Option<String>,
143    pub created_by: Option<String>,
144    pub status: Option<PollStatus>,
145    pub poll_type: Option<PollType>,
146    pub ends_before: Option<String>,
147    pub ends_after: Option<String>,
148}
149
150// ============================================================================
151// From implementations for converting domain entities to DTOs
152// ============================================================================
153
154impl From<Poll> for PollResponseDto {
155    fn from(poll: Poll) -> Self {
156        let status_clone = poll.status.clone();
157        let is_active = status_clone == PollStatus::Active && Utc::now() <= poll.ends_at;
158        let is_ended = Utc::now() > poll.ends_at;
159
160        // Calculate winning option for YesNo/MultipleChoice polls
161        let winning_option = if matches!(poll.poll_type, PollType::YesNo | PollType::MultipleChoice)
162        {
163            poll.options
164                .iter()
165                .max_by_key(|opt| opt.vote_count)
166                .map(|opt| {
167                    let vote_percentage = if poll.total_votes_cast > 0 {
168                        (opt.vote_count as f64 / poll.total_votes_cast as f64) * 100.0
169                    } else {
170                        0.0
171                    };
172                    PollOptionDto {
173                        id: opt.id.to_string(),
174                        option_text: opt.option_text.clone(),
175                        attachment_url: opt.attachment_url.clone(),
176                        vote_count: opt.vote_count,
177                        vote_percentage,
178                        display_order: opt.display_order,
179                    }
180                })
181        } else {
182            None
183        };
184
185        // Convert options with vote percentages
186        let options = poll
187            .options
188            .iter()
189            .map(|opt| {
190                let vote_percentage = if poll.total_votes_cast > 0 {
191                    (opt.vote_count as f64 / poll.total_votes_cast as f64) * 100.0
192                } else {
193                    0.0
194                };
195                PollOptionDto {
196                    id: opt.id.to_string(),
197                    option_text: opt.option_text.clone(),
198                    attachment_url: opt.attachment_url.clone(),
199                    vote_count: opt.vote_count,
200                    vote_percentage,
201                    display_order: opt.display_order,
202                }
203            })
204            .collect();
205
206        // Calculate participation rate before moving poll
207        let participation_rate = poll.participation_rate();
208
209        Self {
210            id: poll.id.to_string(),
211            building_id: poll.building_id.to_string(),
212            created_by: poll.created_by.to_string(),
213            title: poll.title,
214            description: poll.description,
215            poll_type: poll.poll_type,
216            options,
217            is_anonymous: poll.is_anonymous,
218            allow_multiple_votes: poll.allow_multiple_votes,
219            require_all_owners: poll.require_all_owners,
220            starts_at: poll.starts_at.to_rfc3339(),
221            ends_at: poll.ends_at.to_rfc3339(),
222            status: status_clone,
223            total_eligible_voters: poll.total_eligible_voters,
224            total_votes_cast: poll.total_votes_cast,
225            participation_rate,
226            is_active,
227            is_ended,
228            winning_option,
229            created_at: poll.created_at.to_rfc3339(),
230            updated_at: poll.updated_at.to_rfc3339(),
231        }
232    }
233}
234
235impl From<PollVote> for PollVoteResponseDto {
236    fn from(vote: PollVote) -> Self {
237        Self {
238            id: vote.id.to_string(),
239            poll_id: vote.poll_id.to_string(),
240            owner_id: vote.owner_id.map(|id| id.to_string()),
241            building_id: vote.building_id.to_string(),
242            selected_option_ids: vote
243                .selected_option_ids
244                .iter()
245                .map(|id| id.to_string())
246                .collect(),
247            rating_value: vote.rating_value,
248            open_text: vote.open_text,
249            voted_at: vote.voted_at.to_rfc3339(),
250        }
251    }
252}