1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct Poll {
14 pub id: Uuid,
15 pub building_id: Uuid,
16 pub created_by: Uuid, pub title: String,
20 pub description: Option<String>,
21 pub poll_type: PollType,
22 pub options: Vec<PollOption>, pub is_anonymous: bool,
24
25 pub allow_multiple_votes: bool, pub require_all_owners: bool, pub starts_at: DateTime<Utc>,
31 pub ends_at: DateTime<Utc>,
32
33 pub status: PollStatus,
35 pub total_eligible_voters: i32, pub total_votes_cast: i32,
37
38 pub created_at: DateTime<Utc>,
40 pub updated_at: DateTime<Utc>,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
44#[serde(rename_all = "snake_case")]
45pub enum PollType {
46 YesNo, MultipleChoice, Rating, OpenEnded, }
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(rename_all = "snake_case")]
54pub enum PollStatus {
55 Draft, Active, Closed, Cancelled, }
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct PollOption {
63 pub id: Uuid,
64 pub option_text: String,
65 pub attachment_url: Option<String>, pub vote_count: i32,
67 pub display_order: i32,
68}
69
70impl Poll {
71 #[allow(clippy::too_many_arguments)]
72 pub fn new(
73 building_id: Uuid,
74 created_by: Uuid,
75 title: String,
76 description: Option<String>,
77 poll_type: PollType,
78 options: Vec<PollOption>,
79 is_anonymous: bool,
80 ends_at: DateTime<Utc>,
81 total_eligible_voters: i32,
82 ) -> Result<Self, String> {
83 if title.trim().is_empty() {
85 return Err("Poll title cannot be empty".to_string());
86 }
87
88 if ends_at <= Utc::now() {
89 return Err("Poll end date must be in the future".to_string());
90 }
91
92 if total_eligible_voters <= 0 {
93 return Err("Total eligible voters must be positive".to_string());
94 }
95
96 match poll_type {
98 PollType::YesNo => {
99 if options.len() != 2 {
100 return Err("Yes/No polls must have exactly 2 options".to_string());
101 }
102 }
103 PollType::MultipleChoice => {
104 if options.len() < 2 {
105 return Err("Multiple choice polls must have at least 2 options".to_string());
106 }
107 }
108 PollType::Rating => {
109 if options.len() != 5 {
110 return Err("Rating polls must have exactly 5 options (1-5 stars)".to_string());
111 }
112 }
113 PollType::OpenEnded => {
114 if !options.is_empty() {
115 return Err("Open-ended polls cannot have predefined options".to_string());
116 }
117 }
118 }
119
120 let now = Utc::now();
121
122 Ok(Self {
123 id: Uuid::new_v4(),
124 building_id,
125 created_by,
126 title,
127 description,
128 poll_type,
129 options,
130 is_anonymous,
131 allow_multiple_votes: false,
132 require_all_owners: false,
133 starts_at: now,
134 ends_at,
135 status: PollStatus::Draft,
136 total_eligible_voters,
137 total_votes_cast: 0,
138 created_at: now,
139 updated_at: now,
140 })
141 }
142
143 pub fn publish(&mut self) -> Result<(), String> {
145 if self.status != PollStatus::Draft {
146 return Err("Only draft polls can be published".to_string());
147 }
148
149 self.status = PollStatus::Active;
150 self.updated_at = Utc::now();
151 Ok(())
152 }
153
154 pub fn close(&mut self) -> Result<(), String> {
156 if self.status != PollStatus::Active {
157 return Err("Only active polls can be closed".to_string());
158 }
159
160 self.status = PollStatus::Closed;
161 self.updated_at = Utc::now();
162 Ok(())
163 }
164
165 pub fn cancel(&mut self) -> Result<(), String> {
167 if self.status == PollStatus::Closed {
168 return Err("Cannot cancel a closed poll".to_string());
169 }
170
171 self.status = PollStatus::Cancelled;
172 self.updated_at = Utc::now();
173 Ok(())
174 }
175
176 pub fn is_active(&self) -> bool {
178 self.status == PollStatus::Active
179 && Utc::now() >= self.starts_at
180 && Utc::now() <= self.ends_at
181 }
182
183 pub fn is_ended(&self) -> bool {
185 Utc::now() > self.ends_at || self.status == PollStatus::Closed
186 }
187
188 pub fn participation_rate(&self) -> f64 {
190 if self.total_eligible_voters == 0 {
191 return 0.0;
192 }
193 (self.total_votes_cast as f64 / self.total_eligible_voters as f64) * 100.0
194 }
195
196 pub fn get_winning_option(&self) -> Option<&PollOption> {
198 if self.status != PollStatus::Closed {
199 return None;
200 }
201
202 self.options.iter().max_by_key(|opt| opt.vote_count)
203 }
204
205 pub fn record_vote(&mut self, option_id: Uuid) -> Result<(), String> {
207 if !self.is_active() {
208 return Err("Poll is not currently accepting votes".to_string());
209 }
210
211 let option = self
212 .options
213 .iter_mut()
214 .find(|opt| opt.id == option_id)
215 .ok_or("Option not found".to_string())?;
216
217 option.vote_count += 1;
218 self.total_votes_cast += 1;
219 self.updated_at = Utc::now();
220
221 Ok(())
222 }
223
224 pub fn auto_close_if_ended(&mut self) -> bool {
226 if self.status == PollStatus::Active && Utc::now() > self.ends_at {
227 self.status = PollStatus::Closed;
228 self.updated_at = Utc::now();
229 true
230 } else {
231 false
232 }
233 }
234}
235
236impl PollOption {
237 pub fn new(option_text: String, attachment_url: Option<String>, display_order: i32) -> Self {
238 Self {
239 id: Uuid::new_v4(),
240 option_text,
241 attachment_url,
242 vote_count: 0,
243 display_order,
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn test_poll_creation_yes_no() {
254 let options = vec![
255 PollOption::new("Oui".to_string(), None, 1),
256 PollOption::new("Non".to_string(), None, 2),
257 ];
258
259 let poll = Poll::new(
260 Uuid::new_v4(),
261 Uuid::new_v4(),
262 "Repeindre le hall en vert?".to_string(),
263 Some("Vote pour couleur hall".to_string()),
264 PollType::YesNo,
265 options,
266 false,
267 Utc::now() + chrono::Duration::days(7),
268 60,
269 );
270
271 assert!(poll.is_ok());
272 let poll = poll.unwrap();
273 assert_eq!(poll.status, PollStatus::Draft);
274 assert_eq!(poll.options.len(), 2);
275 assert!(!poll.is_active());
276 }
277
278 #[test]
279 fn test_poll_publish_and_close() {
280 let options = vec![
281 PollOption::new("Oui".to_string(), None, 1),
282 PollOption::new("Non".to_string(), None, 2),
283 ];
284
285 let mut poll = Poll::new(
286 Uuid::new_v4(),
287 Uuid::new_v4(),
288 "Test poll".to_string(),
289 None,
290 PollType::YesNo,
291 options,
292 false,
293 Utc::now() + chrono::Duration::days(7),
294 60,
295 )
296 .unwrap();
297
298 assert!(poll.publish().is_ok());
300 assert_eq!(poll.status, PollStatus::Active);
301 assert!(poll.is_active());
302
303 assert!(poll.publish().is_err());
305
306 assert!(poll.close().is_ok());
308 assert_eq!(poll.status, PollStatus::Closed);
309 assert!(!poll.is_active());
310 }
311
312 #[test]
313 fn test_poll_record_vote() {
314 let options = vec![
315 PollOption::new("Option A".to_string(), None, 1),
316 PollOption::new("Option B".to_string(), None, 2),
317 ];
318
319 let mut poll = Poll::new(
320 Uuid::new_v4(),
321 Uuid::new_v4(),
322 "Choose contractor".to_string(),
323 None,
324 PollType::MultipleChoice,
325 options.clone(),
326 false,
327 Utc::now() + chrono::Duration::days(7),
328 60,
329 )
330 .unwrap();
331
332 assert!(poll.record_vote(options[0].id).is_err());
334
335 poll.publish().unwrap();
337
338 assert!(poll.record_vote(options[0].id).is_ok());
340 assert_eq!(poll.total_votes_cast, 1);
341 assert_eq!(poll.options[0].vote_count, 1);
342 }
343
344 #[test]
345 fn test_poll_participation_rate() {
346 let options = vec![
347 PollOption::new("Oui".to_string(), None, 1),
348 PollOption::new("Non".to_string(), None, 2),
349 ];
350
351 let mut poll = Poll::new(
352 Uuid::new_v4(),
353 Uuid::new_v4(),
354 "Test".to_string(),
355 None,
356 PollType::YesNo,
357 options,
358 false,
359 Utc::now() + chrono::Duration::days(7),
360 100,
361 )
362 .unwrap();
363
364 poll.total_votes_cast = 45;
365 assert_eq!(poll.participation_rate(), 45.0);
366
367 poll.total_votes_cast = 100;
368 assert_eq!(poll.participation_rate(), 100.0);
369 }
370
371 #[test]
372 fn test_poll_validation() {
373 let result = Poll::new(
375 Uuid::new_v4(),
376 Uuid::new_v4(),
377 "".to_string(),
378 None,
379 PollType::YesNo,
380 vec![],
381 false,
382 Utc::now() + chrono::Duration::days(7),
383 60,
384 );
385 assert!(result.is_err());
386
387 let result = Poll::new(
389 Uuid::new_v4(),
390 Uuid::new_v4(),
391 "Test".to_string(),
392 None,
393 PollType::YesNo,
394 vec![],
395 false,
396 Utc::now() - chrono::Duration::days(1),
397 60,
398 );
399 assert!(result.is_err());
400 }
401}