koprogo_api/domain/entities/
poll.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Poll - Sondage pour décisions du conseil de copropriété
6///
7/// Permet au conseil de consulter rapidement les résidents sur des décisions:
8/// - Choix entrepreneur (avec devis attachés)
9/// - Couleur de peinture
10/// - Horaires de travaux
11/// - Décisions mineures entre AG
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct Poll {
14    pub id: Uuid,
15    pub building_id: Uuid,
16    pub created_by: Uuid, // Board member or syndic who created the poll
17
18    // Poll details
19    pub title: String,
20    pub description: Option<String>,
21    pub poll_type: PollType,
22    pub options: Vec<PollOption>, // Empty for OpenEnded polls
23    pub is_anonymous: bool,
24
25    // Voting settings
26    pub allow_multiple_votes: bool, // For MultipleChoice polls
27    pub require_all_owners: bool,   // Require 100% participation
28
29    // Schedule
30    pub starts_at: DateTime<Utc>,
31    pub ends_at: DateTime<Utc>,
32
33    // Status
34    pub status: PollStatus,
35    pub total_eligible_voters: i32, // Total owners who can vote
36    pub total_votes_cast: i32,
37
38    // Metadata
39    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,          // Simple yes/no question
47    MultipleChoice, // Choose one or multiple options
48    Rating,         // Rate 1-5 stars
49    OpenEnded,      // Free text responses
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(rename_all = "snake_case")]
54pub enum PollStatus {
55    Draft,     // Not yet published
56    Active,    // Currently accepting votes
57    Closed,    // Voting period ended
58    Cancelled, // Poll cancelled before completion
59}
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>, // PDF devis, image, etc.
66    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        // Validation
84        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        // Validate options based on poll type
97        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    /// Publish the poll (make it active)
144    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    /// Close the poll (end voting)
155    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    /// Cancel the poll
166    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    /// Check if poll is currently active and accepting votes
177    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    /// Check if poll has ended
184    pub fn is_ended(&self) -> bool {
185        Utc::now() > self.ends_at || self.status == PollStatus::Closed
186    }
187
188    /// Calculate participation rate
189    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    /// Get winning option (for closed polls)
197    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    /// Update vote count for an option
206    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    /// Auto-close if past end date
225    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        // Publish poll
299        assert!(poll.publish().is_ok());
300        assert_eq!(poll.status, PollStatus::Active);
301        assert!(poll.is_active());
302
303        // Cannot publish again
304        assert!(poll.publish().is_err());
305
306        // Close poll
307        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        // Cannot vote on draft poll
333        assert!(poll.record_vote(options[0].id).is_err());
334
335        // Publish poll
336        poll.publish().unwrap();
337
338        // Record vote
339        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        // Empty title
374        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        // Past end date
388        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}