koprogo_api/domain/entities/
convocation.rs

1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5/// Convocation type according to Belgian copropriété law
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum ConvocationType {
8    /// Ordinary General Assembly (15 days minimum notice)
9    Ordinary,
10    /// Extraordinary General Assembly (8 days minimum notice)
11    Extraordinary,
12    /// Second convocation after quorum not reached (8 days minimum notice)
13    SecondConvocation,
14}
15
16impl ConvocationType {
17    /// Get minimum notice period in days according to Belgian law
18    pub fn minimum_notice_days(&self) -> i64 {
19        match self {
20            ConvocationType::Ordinary => 15,
21            ConvocationType::Extraordinary | ConvocationType::SecondConvocation => 8,
22        }
23    }
24
25    /// Convert to database string
26    pub fn to_db_string(&self) -> &'static str {
27        match self {
28            ConvocationType::Ordinary => "ordinary",
29            ConvocationType::Extraordinary => "extraordinary",
30            ConvocationType::SecondConvocation => "second_convocation",
31        }
32    }
33
34    /// Parse from database string
35    pub fn from_db_string(s: &str) -> Result<Self, String> {
36        match s {
37            "ordinary" => Ok(ConvocationType::Ordinary),
38            "extraordinary" => Ok(ConvocationType::Extraordinary),
39            "second_convocation" => Ok(ConvocationType::SecondConvocation),
40            _ => Err(format!("Invalid meeting type: {}", s)),
41        }
42    }
43}
44
45/// Convocation status
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub enum ConvocationStatus {
48    /// Draft (not yet sent)
49    Draft,
50    /// Scheduled (will be sent at scheduled time)
51    Scheduled,
52    /// Sent (emails dispatched)
53    Sent,
54    /// Cancelled (meeting cancelled)
55    Cancelled,
56}
57
58impl ConvocationStatus {
59    pub fn to_db_string(&self) -> &'static str {
60        match self {
61            ConvocationStatus::Draft => "draft",
62            ConvocationStatus::Scheduled => "scheduled",
63            ConvocationStatus::Sent => "sent",
64            ConvocationStatus::Cancelled => "cancelled",
65        }
66    }
67
68    pub fn from_db_string(s: &str) -> Result<Self, String> {
69        match s {
70            "draft" => Ok(ConvocationStatus::Draft),
71            "scheduled" => Ok(ConvocationStatus::Scheduled),
72            "sent" => Ok(ConvocationStatus::Sent),
73            "cancelled" => Ok(ConvocationStatus::Cancelled),
74            _ => Err(format!("Invalid convocation status: {}", s)),
75        }
76    }
77}
78
79/// Convocation entity - Automatic meeting invitations with legal compliance
80///
81/// Implements Belgian copropriété legal requirements for meeting convocations:
82/// - Ordinary AG: 15 days minimum notice
83/// - Extraordinary AG: 8 days minimum notice
84/// - Second convocation: 8 days minimum notice
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Convocation {
87    pub id: Uuid,
88    pub organization_id: Uuid,
89    pub building_id: Uuid,
90    pub meeting_id: Uuid,
91    pub meeting_type: ConvocationType,
92    pub meeting_date: DateTime<Utc>,
93    pub status: ConvocationStatus,
94
95    // Legal deadline tracking
96    pub minimum_send_date: DateTime<Utc>, // Latest date to send (meeting_date - minimum_notice_days)
97    pub actual_send_date: Option<DateTime<Utc>>, // When actually sent
98    pub scheduled_send_date: Option<DateTime<Utc>>, // When scheduled to be sent
99
100    // PDF generation
101    pub pdf_file_path: Option<String>, // Path to generated PDF
102    pub language: String,              // FR, NL, DE, EN
103
104    // Tracking
105    pub total_recipients: i32,
106    pub opened_count: i32,
107    pub will_attend_count: i32,
108    pub will_not_attend_count: i32,
109
110    // Reminders
111    pub reminder_sent_at: Option<DateTime<Utc>>, // J-3 reminder
112
113    // Audit
114    pub created_at: DateTime<Utc>,
115    pub updated_at: DateTime<Utc>,
116    pub created_by: Uuid,
117}
118
119impl Convocation {
120    /// Create a new convocation
121    ///
122    /// # Arguments
123    /// * `organization_id` - Organization ID
124    /// * `building_id` - Building ID
125    /// * `meeting_id` - Meeting ID
126    /// * `meeting_type` - Type of meeting (Ordinary/Extraordinary/Second)
127    /// * `meeting_date` - Scheduled meeting date
128    /// * `language` - Convocation language (FR/NL/DE/EN)
129    /// * `created_by` - User creating the convocation
130    ///
131    /// # Returns
132    /// Result with Convocation or error if meeting date is too soon
133    pub fn new(
134        organization_id: Uuid,
135        building_id: Uuid,
136        meeting_id: Uuid,
137        meeting_type: ConvocationType,
138        meeting_date: DateTime<Utc>,
139        language: String,
140        created_by: Uuid,
141    ) -> Result<Self, String> {
142        // Validate language
143        if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
144            return Err(format!(
145                "Invalid language '{}'. Must be FR, NL, DE, or EN",
146                language
147            ));
148        }
149
150        // Calculate minimum send date (meeting_date - minimum_notice_days)
151        let minimum_notice_days = meeting_type.minimum_notice_days();
152        let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
153
154        // Check if meeting date allows for legal notice period
155        let now = Utc::now();
156        if minimum_send_date < now {
157            return Err(format!(
158                "Meeting date too soon. {} meeting requires {} days notice. Minimum send date would be {}",
159                match meeting_type {
160                    ConvocationType::Ordinary => "Ordinary",
161                    ConvocationType::Extraordinary => "Extraordinary",
162                    ConvocationType::SecondConvocation => "Second convocation",
163                },
164                minimum_notice_days,
165                minimum_send_date.format("%Y-%m-%d %H:%M")
166            ));
167        }
168
169        Ok(Self {
170            id: Uuid::new_v4(),
171            organization_id,
172            building_id,
173            meeting_id,
174            meeting_type,
175            meeting_date,
176            status: ConvocationStatus::Draft,
177            minimum_send_date,
178            actual_send_date: None,
179            scheduled_send_date: None,
180            pdf_file_path: None,
181            language: language.to_uppercase(),
182            total_recipients: 0,
183            opened_count: 0,
184            will_attend_count: 0,
185            will_not_attend_count: 0,
186            reminder_sent_at: None,
187            created_at: now,
188            updated_at: now,
189            created_by,
190        })
191    }
192
193    /// Schedule convocation to be sent at specific date
194    pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
195        if self.status != ConvocationStatus::Draft {
196            return Err(format!(
197                "Cannot schedule convocation in status '{:?}'. Must be Draft",
198                self.status
199            ));
200        }
201
202        // Verify send_date is before meeting_date - minimum_notice_days
203        if send_date > self.minimum_send_date {
204            return Err(format!(
205                "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
206                send_date.format("%Y-%m-%d %H:%M"),
207                self.minimum_send_date.format("%Y-%m-%d %H:%M")
208            ));
209        }
210
211        self.scheduled_send_date = Some(send_date);
212        self.status = ConvocationStatus::Scheduled;
213        self.updated_at = Utc::now();
214        Ok(())
215    }
216
217    /// Mark convocation as sent
218    pub fn mark_sent(
219        &mut self,
220        pdf_file_path: String,
221        total_recipients: i32,
222    ) -> Result<(), String> {
223        if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
224            return Err(format!(
225                "Cannot send convocation in status '{:?}'",
226                self.status
227            ));
228        }
229
230        if total_recipients <= 0 {
231            return Err("Total recipients must be greater than 0".to_string());
232        }
233
234        self.status = ConvocationStatus::Sent;
235        self.actual_send_date = Some(Utc::now());
236        self.pdf_file_path = Some(pdf_file_path);
237        self.total_recipients = total_recipients;
238        self.updated_at = Utc::now();
239        Ok(())
240    }
241
242    /// Cancel convocation
243    pub fn cancel(&mut self) -> Result<(), String> {
244        if self.status == ConvocationStatus::Cancelled {
245            return Err("Convocation is already cancelled".to_string());
246        }
247
248        self.status = ConvocationStatus::Cancelled;
249        self.updated_at = Utc::now();
250        Ok(())
251    }
252
253    /// Mark reminder as sent (J-3)
254    pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
255        if self.status != ConvocationStatus::Sent {
256            return Err("Cannot send reminder for unsent convocation".to_string());
257        }
258
259        self.reminder_sent_at = Some(Utc::now());
260        self.updated_at = Utc::now();
261        Ok(())
262    }
263
264    /// Update tracking counts from recipients
265    pub fn update_tracking_counts(
266        &mut self,
267        opened_count: i32,
268        will_attend_count: i32,
269        will_not_attend_count: i32,
270    ) {
271        self.opened_count = opened_count;
272        self.will_attend_count = will_attend_count;
273        self.will_not_attend_count = will_not_attend_count;
274        self.updated_at = Utc::now();
275    }
276
277    /// Check if convocation respects legal deadline
278    pub fn respects_legal_deadline(&self) -> bool {
279        match &self.actual_send_date {
280            Some(sent_at) => *sent_at <= self.minimum_send_date,
281            None => false, // Not sent yet
282        }
283    }
284
285    /// Get days until meeting
286    pub fn days_until_meeting(&self) -> i64 {
287        let now = Utc::now();
288        let duration = self.meeting_date.signed_duration_since(now);
289        duration.num_days()
290    }
291
292    /// Check if reminder should be sent (3 days before meeting)
293    pub fn should_send_reminder(&self) -> bool {
294        if self.status != ConvocationStatus::Sent {
295            return false;
296        }
297
298        if self.reminder_sent_at.is_some() {
299            return false; // Already sent
300        }
301
302        let days_until = self.days_until_meeting();
303        days_until <= 3 && days_until >= 0
304    }
305
306    /// Get opening rate (percentage of recipients who opened)
307    pub fn opening_rate(&self) -> f64 {
308        if self.total_recipients == 0 {
309            return 0.0;
310        }
311        (self.opened_count as f64 / self.total_recipients as f64) * 100.0
312    }
313
314    /// Get attendance rate (percentage confirmed attending)
315    pub fn attendance_rate(&self) -> f64 {
316        if self.total_recipients == 0 {
317            return 0.0;
318        }
319        (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_meeting_type_minimum_notice_days() {
329        assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
330        assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 8);
331        assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 8);
332    }
333
334    #[test]
335    fn test_create_convocation_success() {
336        let org_id = Uuid::new_v4();
337        let building_id = Uuid::new_v4();
338        let meeting_id = Uuid::new_v4();
339        let creator_id = Uuid::new_v4();
340        let meeting_date = Utc::now() + Duration::days(20);
341
342        let convocation = Convocation::new(
343            org_id,
344            building_id,
345            meeting_id,
346            ConvocationType::Ordinary,
347            meeting_date,
348            "FR".to_string(),
349            creator_id,
350        );
351
352        assert!(convocation.is_ok());
353        let conv = convocation.unwrap();
354        assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
355        assert_eq!(conv.language, "FR");
356        assert_eq!(conv.status, ConvocationStatus::Draft);
357        assert_eq!(conv.total_recipients, 0);
358    }
359
360    #[test]
361    fn test_create_convocation_meeting_too_soon() {
362        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days notice for ordinary meeting
363
364        let result = Convocation::new(
365            Uuid::new_v4(),
366            Uuid::new_v4(),
367            Uuid::new_v4(),
368            ConvocationType::Ordinary, // Requires 15 days
369            meeting_date,
370            "FR".to_string(),
371            Uuid::new_v4(),
372        );
373
374        assert!(result.is_err());
375        assert!(result.unwrap_err().contains("Meeting date too soon"));
376    }
377
378    #[test]
379    fn test_create_convocation_invalid_language() {
380        let meeting_date = Utc::now() + Duration::days(20);
381
382        let result = Convocation::new(
383            Uuid::new_v4(),
384            Uuid::new_v4(),
385            Uuid::new_v4(),
386            ConvocationType::Ordinary,
387            meeting_date,
388            "ES".to_string(), // Spanish not supported
389            Uuid::new_v4(),
390        );
391
392        assert!(result.is_err());
393        assert!(result.unwrap_err().contains("Invalid language"));
394    }
395
396    #[test]
397    fn test_schedule_convocation() {
398        let meeting_date = Utc::now() + Duration::days(20);
399        let mut convocation = Convocation::new(
400            Uuid::new_v4(),
401            Uuid::new_v4(),
402            Uuid::new_v4(),
403            ConvocationType::Ordinary,
404            meeting_date,
405            "FR".to_string(),
406            Uuid::new_v4(),
407        )
408        .unwrap();
409
410        let send_date = Utc::now() + Duration::days(3); // Send in 3 days
411        let result = convocation.schedule(send_date);
412
413        assert!(result.is_ok());
414        assert_eq!(convocation.status, ConvocationStatus::Scheduled);
415        assert_eq!(convocation.scheduled_send_date, Some(send_date));
416    }
417
418    #[test]
419    fn test_schedule_convocation_too_late() {
420        let meeting_date = Utc::now() + Duration::days(20);
421        let mut convocation = Convocation::new(
422            Uuid::new_v4(),
423            Uuid::new_v4(),
424            Uuid::new_v4(),
425            ConvocationType::Ordinary,
426            meeting_date,
427            "FR".to_string(),
428            Uuid::new_v4(),
429        )
430        .unwrap();
431
432        // Try to schedule send date after minimum_send_date
433        let send_date = meeting_date - Duration::days(10); // Only 10 days before (needs 15)
434        let result = convocation.schedule(send_date);
435
436        assert!(result.is_err());
437        assert!(result.unwrap_err().contains("after minimum send date"));
438    }
439
440    #[test]
441    fn test_mark_sent() {
442        let meeting_date = Utc::now() + Duration::days(20);
443        let mut convocation = Convocation::new(
444            Uuid::new_v4(),
445            Uuid::new_v4(),
446            Uuid::new_v4(),
447            ConvocationType::Ordinary,
448            meeting_date,
449            "FR".to_string(),
450            Uuid::new_v4(),
451        )
452        .unwrap();
453
454        let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
455
456        assert!(result.is_ok());
457        assert_eq!(convocation.status, ConvocationStatus::Sent);
458        assert!(convocation.actual_send_date.is_some());
459        assert_eq!(convocation.total_recipients, 50);
460        assert_eq!(
461            convocation.pdf_file_path,
462            Some("/uploads/convocations/conv-123.pdf".to_string())
463        );
464    }
465
466    #[test]
467    fn test_should_send_reminder() {
468        // Test case 1: Meeting in 10 days - should NOT send reminder (too early)
469        let far_meeting_date = Utc::now() + Duration::days(10);
470        let mut convocation_far = Convocation::new(
471            Uuid::new_v4(),
472            Uuid::new_v4(),
473            Uuid::new_v4(),
474            ConvocationType::Extraordinary, // 8 days notice
475            far_meeting_date,
476            "FR".to_string(),
477            Uuid::new_v4(),
478        )
479        .unwrap();
480
481        convocation_far
482            .mark_sent("/uploads/conv.pdf".to_string(), 30)
483            .unwrap();
484
485        // Should NOT send reminder yet (meeting is 10 days away, reminder threshold is 3 days)
486        assert!(!convocation_far.should_send_reminder());
487
488        // Test case 2: For a meeting within 3 days, we'd need to create it with proper notice
489        // and then wait. Since we can't time-travel in tests, we just verify the logic
490        // that reminders are sent within 3 days of meeting.
491        // The actual production code would check this daily via a cron job.
492    }
493
494    #[test]
495    fn test_opening_rate() {
496        let meeting_date = Utc::now() + Duration::days(20);
497        let mut convocation = Convocation::new(
498            Uuid::new_v4(),
499            Uuid::new_v4(),
500            Uuid::new_v4(),
501            ConvocationType::Ordinary,
502            meeting_date,
503            "FR".to_string(),
504            Uuid::new_v4(),
505        )
506        .unwrap();
507
508        convocation
509            .mark_sent("/uploads/conv.pdf".to_string(), 100)
510            .unwrap();
511        convocation.update_tracking_counts(75, 50, 10);
512
513        assert_eq!(convocation.opening_rate(), 75.0);
514        assert_eq!(convocation.attendance_rate(), 50.0);
515    }
516
517    #[test]
518    fn test_respects_legal_deadline() {
519        let meeting_date = Utc::now() + Duration::days(20);
520        let mut convocation = Convocation::new(
521            Uuid::new_v4(),
522            Uuid::new_v4(),
523            Uuid::new_v4(),
524            ConvocationType::Ordinary,
525            meeting_date,
526            "FR".to_string(),
527            Uuid::new_v4(),
528        )
529        .unwrap();
530
531        // Before sending
532        assert!(!convocation.respects_legal_deadline());
533
534        // After sending (now is definitely before minimum_send_date)
535        convocation
536            .mark_sent("/uploads/conv.pdf".to_string(), 30)
537            .unwrap();
538        assert!(convocation.respects_legal_deadline());
539    }
540}