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/// Art. 3.87 §3 Code Civil (ex Art. 577-6 §2): minimum 15 days notice for ALL types
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub enum ConvocationType {
9    /// Ordinary General Assembly (15 days minimum notice)
10    Ordinary,
11    /// Extraordinary General Assembly (15 days minimum notice - same as ordinary per Art. 3.87 §3)
12    Extraordinary,
13    /// Second convocation after quorum not reached (15 days minimum notice - Art. 3.87 §5)
14    SecondConvocation,
15}
16
17impl ConvocationType {
18    /// Get minimum notice period in days according to Belgian law
19    /// Art. 3.87 §3 Code Civil: "Sauf dans les cas d'urgence, la convocation est
20    /// communiquée quinze jours au moins avant la date de l'assemblée."
21    /// This 15-day minimum applies to ALL assembly types (ordinary, extraordinary,
22    /// and second convocation after quorum failure).
23    pub fn minimum_notice_days(&self) -> i64 {
24        // Belgian law notice periods per Art. 3.87 §3 Code Civil:
25        // "Sauf dans les cas d'urgence, la convocation est communiquée quinze jours
26        // au moins avant la date de l'assemblée."
27        // 15 days minimum for ALL assembly types (ordinary, extraordinary, second convocation).
28        match self {
29            ConvocationType::Ordinary
30            | ConvocationType::Extraordinary
31            | ConvocationType::SecondConvocation => 15,
32        }
33    }
34
35    /// Convert to database string
36    pub fn to_db_string(&self) -> &'static str {
37        match self {
38            ConvocationType::Ordinary => "ordinary",
39            ConvocationType::Extraordinary => "extraordinary",
40            ConvocationType::SecondConvocation => "second_convocation",
41        }
42    }
43
44    /// Parse from database string
45    pub fn from_db_string(s: &str) -> Result<Self, String> {
46        match s {
47            "ordinary" => Ok(ConvocationType::Ordinary),
48            "extraordinary" => Ok(ConvocationType::Extraordinary),
49            "second_convocation" => Ok(ConvocationType::SecondConvocation),
50            _ => Err(format!("Invalid meeting type: {}", s)),
51        }
52    }
53}
54
55/// Convocation status
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub enum ConvocationStatus {
58    /// Draft (not yet sent)
59    Draft,
60    /// Scheduled (will be sent at scheduled time)
61    Scheduled,
62    /// Sent (emails dispatched)
63    Sent,
64    /// Cancelled (meeting cancelled)
65    Cancelled,
66}
67
68impl ConvocationStatus {
69    pub fn to_db_string(&self) -> &'static str {
70        match self {
71            ConvocationStatus::Draft => "draft",
72            ConvocationStatus::Scheduled => "scheduled",
73            ConvocationStatus::Sent => "sent",
74            ConvocationStatus::Cancelled => "cancelled",
75        }
76    }
77
78    pub fn from_db_string(s: &str) -> Result<Self, String> {
79        match s {
80            "draft" => Ok(ConvocationStatus::Draft),
81            "scheduled" => Ok(ConvocationStatus::Scheduled),
82            "sent" => Ok(ConvocationStatus::Sent),
83            "cancelled" => Ok(ConvocationStatus::Cancelled),
84            _ => Err(format!("Invalid convocation status: {}", s)),
85        }
86    }
87}
88
89/// Convocation entity - Automatic meeting invitations with legal compliance
90///
91/// Implements Belgian copropriété legal requirements for meeting convocations:
92/// Art. 3.87 §3 Code Civil: 15 days minimum notice for ALL types
93/// (Ordinary, Extraordinary, and Second Convocation after quorum failure)
94/// Art. 3.87 §5 CC: 2e convocation si quorum non atteint — pas de quorum minimum requis
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Convocation {
97    pub id: Uuid,
98    pub organization_id: Uuid,
99    pub building_id: Uuid,
100    pub meeting_id: Uuid,
101    pub meeting_type: ConvocationType,
102    pub meeting_date: DateTime<Utc>,
103    pub status: ConvocationStatus,
104
105    // Lien vers la 1ère AG si 2e convocation (quorum non atteint — Art. 3.87 §5 CC)
106    pub first_meeting_id: Option<Uuid>,
107
108    // For second convocation: quorum is NOT required (Art. 3.87 §5 CC)
109    // "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
110    pub no_quorum_required: bool,
111
112    // Legal deadline tracking
113    pub minimum_send_date: DateTime<Utc>, // Latest date to send (meeting_date - minimum_notice_days)
114    pub actual_send_date: Option<DateTime<Utc>>, // When actually sent
115    pub scheduled_send_date: Option<DateTime<Utc>>, // When scheduled to be sent
116
117    // PDF generation
118    pub pdf_file_path: Option<String>, // Path to generated PDF
119    pub language: String,              // FR, NL, DE, EN
120
121    // Tracking
122    pub total_recipients: i32,
123    pub opened_count: i32,
124    pub will_attend_count: i32,
125    pub will_not_attend_count: i32,
126
127    // Reminders
128    pub reminder_sent_at: Option<DateTime<Utc>>, // J-3 reminder
129
130    // Audit
131    pub created_at: DateTime<Utc>,
132    pub updated_at: DateTime<Utc>,
133    pub created_by: Uuid,
134}
135
136impl Convocation {
137    /// Create a new convocation
138    ///
139    /// # Arguments
140    /// * `organization_id` - Organization ID
141    /// * `building_id` - Building ID
142    /// * `meeting_id` - Meeting ID
143    /// * `meeting_type` - Type of meeting (Ordinary/Extraordinary/Second)
144    /// * `meeting_date` - Scheduled meeting date
145    /// * `language` - Convocation language (FR/NL/DE/EN)
146    /// * `created_by` - User creating the convocation
147    ///
148    /// # Returns
149    /// Result with Convocation or error if meeting date is too soon
150    pub fn new(
151        organization_id: Uuid,
152        building_id: Uuid,
153        meeting_id: Uuid,
154        meeting_type: ConvocationType,
155        meeting_date: DateTime<Utc>,
156        language: String,
157        created_by: Uuid,
158    ) -> Result<Self, String> {
159        // Validate language
160        if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
161            return Err(format!(
162                "Invalid language '{}'. Must be FR, NL, DE, or EN",
163                language
164            ));
165        }
166
167        // Calculate minimum send date (meeting_date - minimum_notice_days)
168        let minimum_notice_days = meeting_type.minimum_notice_days();
169        let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
170
171        // Check if meeting date allows for legal notice period
172        let now = Utc::now();
173        if minimum_send_date < now {
174            return Err(format!(
175                "Meeting date too soon. {} meeting requires {} days notice. Minimum send date would be {}",
176                match meeting_type {
177                    ConvocationType::Ordinary => "Ordinary",
178                    ConvocationType::Extraordinary => "Extraordinary",
179                    ConvocationType::SecondConvocation => "Second convocation",
180                },
181                minimum_notice_days,
182                minimum_send_date.format("%Y-%m-%d %H:%M")
183            ));
184        }
185
186        Ok(Self {
187            id: Uuid::new_v4(),
188            organization_id,
189            building_id,
190            meeting_id,
191            meeting_type,
192            meeting_date,
193            status: ConvocationStatus::Draft,
194            first_meeting_id: None,
195            no_quorum_required: false, // Only set to true for second convocations
196            minimum_send_date,
197            actual_send_date: None,
198            scheduled_send_date: None,
199            pdf_file_path: None,
200            language: language.to_uppercase(),
201            total_recipients: 0,
202            opened_count: 0,
203            will_attend_count: 0,
204            will_not_attend_count: 0,
205            reminder_sent_at: None,
206            created_at: now,
207            updated_at: now,
208            created_by,
209        })
210    }
211
212    /// Crée une 2e convocation après échec du quorum (Art. 3.87 §5 CC).
213    ///
214    /// Règles légales:
215    /// - La 2e AG doit avoir lieu ≥15 jours après la 1ère AG (Art. 3.87 §3)
216    /// - La 2e AG délibère valablement quel que soit le nombre de présents
217    ///   (aucun quorum minimum requis)
218    /// - Le contenu de l'ordre du jour est identique à la 1ère AG
219    pub fn new_second_convocation(
220        organization_id: Uuid,
221        building_id: Uuid,
222        new_meeting_id: Uuid,
223        first_meeting_id: Uuid,
224        first_meeting_date: DateTime<Utc>,
225        new_meeting_date: DateTime<Utc>,
226        language: String,
227        created_by: Uuid,
228    ) -> Result<Self, String> {
229        // Validation: la 2e AG doit être au moins 15 jours après la 1ère
230        let min_second_date = first_meeting_date + Duration::days(15);
231        if new_meeting_date < min_second_date {
232            return Err(format!(
233                "Second convocation meeting date {} must be at least 15 days after the first \
234                 meeting date {} (Art. 3.87 §3 CC). Minimum date: {}",
235                new_meeting_date.format("%Y-%m-%d"),
236                first_meeting_date.format("%Y-%m-%d"),
237                min_second_date.format("%Y-%m-%d")
238            ));
239        }
240
241        let mut convocation = Self::new(
242            organization_id,
243            building_id,
244            new_meeting_id,
245            ConvocationType::SecondConvocation,
246            new_meeting_date,
247            language,
248            created_by,
249        )?;
250
251        convocation.first_meeting_id = Some(first_meeting_id);
252        // Art. 3.87 §5 CC: "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
253        convocation.no_quorum_required = true;
254        Ok(convocation)
255    }
256
257    /// Schedule convocation to be sent at specific date
258    pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
259        if self.status != ConvocationStatus::Draft {
260            return Err(format!(
261                "Cannot schedule convocation in status '{:?}'. Must be Draft",
262                self.status
263            ));
264        }
265
266        // Verify send_date is before meeting_date - minimum_notice_days
267        if send_date > self.minimum_send_date {
268            return Err(format!(
269                "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
270                send_date.format("%Y-%m-%d %H:%M"),
271                self.minimum_send_date.format("%Y-%m-%d %H:%M")
272            ));
273        }
274
275        self.scheduled_send_date = Some(send_date);
276        self.status = ConvocationStatus::Scheduled;
277        self.updated_at = Utc::now();
278        Ok(())
279    }
280
281    /// Mark convocation as sent
282    pub fn mark_sent(
283        &mut self,
284        pdf_file_path: String,
285        total_recipients: i32,
286    ) -> Result<(), String> {
287        if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
288            return Err(format!(
289                "Cannot send convocation in status '{:?}'",
290                self.status
291            ));
292        }
293
294        if total_recipients <= 0 {
295            return Err("Total recipients must be greater than 0".to_string());
296        }
297
298        self.status = ConvocationStatus::Sent;
299        self.actual_send_date = Some(Utc::now());
300        self.pdf_file_path = Some(pdf_file_path);
301        self.total_recipients = total_recipients;
302        self.updated_at = Utc::now();
303        Ok(())
304    }
305
306    /// Cancel convocation
307    pub fn cancel(&mut self) -> Result<(), String> {
308        if self.status == ConvocationStatus::Cancelled {
309            return Err("Convocation is already cancelled".to_string());
310        }
311
312        self.status = ConvocationStatus::Cancelled;
313        self.updated_at = Utc::now();
314        Ok(())
315    }
316
317    /// Mark reminder as sent (J-3)
318    pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
319        if self.status != ConvocationStatus::Sent {
320            return Err("Cannot send reminder for unsent convocation".to_string());
321        }
322
323        self.reminder_sent_at = Some(Utc::now());
324        self.updated_at = Utc::now();
325        Ok(())
326    }
327
328    /// Update tracking counts from recipients
329    pub fn update_tracking_counts(
330        &mut self,
331        opened_count: i32,
332        will_attend_count: i32,
333        will_not_attend_count: i32,
334    ) {
335        self.opened_count = opened_count;
336        self.will_attend_count = will_attend_count;
337        self.will_not_attend_count = will_not_attend_count;
338        self.updated_at = Utc::now();
339    }
340
341    /// Check if convocation respects legal deadline
342    pub fn respects_legal_deadline(&self) -> bool {
343        match &self.actual_send_date {
344            Some(sent_at) => *sent_at <= self.minimum_send_date,
345            None => {
346                // Not sent yet: still respects deadline if there's time to send
347                Utc::now() <= self.minimum_send_date
348            }
349        }
350    }
351
352    /// Get days until meeting
353    pub fn days_until_meeting(&self) -> i64 {
354        let now = Utc::now();
355        let duration = self.meeting_date.signed_duration_since(now);
356        duration.num_days()
357    }
358
359    /// Check if reminder should be sent (3 days before meeting)
360    pub fn should_send_reminder(&self) -> bool {
361        if self.status != ConvocationStatus::Sent {
362            return false;
363        }
364
365        if self.reminder_sent_at.is_some() {
366            return false; // Already sent
367        }
368
369        let days_until = self.days_until_meeting();
370        days_until <= 3 && days_until >= 0
371    }
372
373    /// Get opening rate (percentage of recipients who opened)
374    pub fn opening_rate(&self) -> f64 {
375        if self.total_recipients == 0 {
376            return 0.0;
377        }
378        (self.opened_count as f64 / self.total_recipients as f64) * 100.0
379    }
380
381    /// Get attendance rate (percentage confirmed attending)
382    pub fn attendance_rate(&self) -> f64 {
383        if self.total_recipients == 0 {
384            return 0.0;
385        }
386        (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_meeting_type_minimum_notice_days() {
396        // Art. 3.87 §3 CC: 15 days for ALL types
397        assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
398        assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 15);
399        assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 15);
400    }
401
402    #[test]
403    fn test_create_convocation_success() {
404        let org_id = Uuid::new_v4();
405        let building_id = Uuid::new_v4();
406        let meeting_id = Uuid::new_v4();
407        let creator_id = Uuid::new_v4();
408        let meeting_date = Utc::now() + Duration::days(20);
409
410        let convocation = Convocation::new(
411            org_id,
412            building_id,
413            meeting_id,
414            ConvocationType::Ordinary,
415            meeting_date,
416            "FR".to_string(),
417            creator_id,
418        );
419
420        assert!(convocation.is_ok());
421        let conv = convocation.unwrap();
422        assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
423        assert_eq!(conv.language, "FR");
424        assert_eq!(conv.status, ConvocationStatus::Draft);
425        assert_eq!(conv.total_recipients, 0);
426    }
427
428    #[test]
429    fn test_create_convocation_meeting_too_soon() {
430        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days notice for ordinary meeting
431
432        let result = Convocation::new(
433            Uuid::new_v4(),
434            Uuid::new_v4(),
435            Uuid::new_v4(),
436            ConvocationType::Ordinary, // Requires 15 days
437            meeting_date,
438            "FR".to_string(),
439            Uuid::new_v4(),
440        );
441
442        assert!(result.is_err());
443        assert!(result.unwrap_err().contains("Meeting date too soon"));
444    }
445
446    #[test]
447    fn test_create_convocation_invalid_language() {
448        let meeting_date = Utc::now() + Duration::days(20);
449
450        let result = Convocation::new(
451            Uuid::new_v4(),
452            Uuid::new_v4(),
453            Uuid::new_v4(),
454            ConvocationType::Ordinary,
455            meeting_date,
456            "ES".to_string(), // Spanish not supported
457            Uuid::new_v4(),
458        );
459
460        assert!(result.is_err());
461        assert!(result.unwrap_err().contains("Invalid language"));
462    }
463
464    #[test]
465    fn test_schedule_convocation() {
466        let meeting_date = Utc::now() + Duration::days(20);
467        let mut convocation = Convocation::new(
468            Uuid::new_v4(),
469            Uuid::new_v4(),
470            Uuid::new_v4(),
471            ConvocationType::Ordinary,
472            meeting_date,
473            "FR".to_string(),
474            Uuid::new_v4(),
475        )
476        .unwrap();
477
478        let send_date = Utc::now() + Duration::days(3); // Send in 3 days
479        let result = convocation.schedule(send_date);
480
481        assert!(result.is_ok());
482        assert_eq!(convocation.status, ConvocationStatus::Scheduled);
483        assert_eq!(convocation.scheduled_send_date, Some(send_date));
484    }
485
486    #[test]
487    fn test_schedule_convocation_too_late() {
488        let meeting_date = Utc::now() + Duration::days(20);
489        let mut convocation = Convocation::new(
490            Uuid::new_v4(),
491            Uuid::new_v4(),
492            Uuid::new_v4(),
493            ConvocationType::Ordinary,
494            meeting_date,
495            "FR".to_string(),
496            Uuid::new_v4(),
497        )
498        .unwrap();
499
500        // Try to schedule send date after minimum_send_date
501        let send_date = meeting_date - Duration::days(10); // Only 10 days before (needs 15)
502        let result = convocation.schedule(send_date);
503
504        assert!(result.is_err());
505        assert!(result.unwrap_err().contains("after minimum send date"));
506    }
507
508    #[test]
509    fn test_mark_sent() {
510        let meeting_date = Utc::now() + Duration::days(20);
511        let mut convocation = Convocation::new(
512            Uuid::new_v4(),
513            Uuid::new_v4(),
514            Uuid::new_v4(),
515            ConvocationType::Ordinary,
516            meeting_date,
517            "FR".to_string(),
518            Uuid::new_v4(),
519        )
520        .unwrap();
521
522        let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
523
524        assert!(result.is_ok());
525        assert_eq!(convocation.status, ConvocationStatus::Sent);
526        assert!(convocation.actual_send_date.is_some());
527        assert_eq!(convocation.total_recipients, 50);
528        assert_eq!(
529            convocation.pdf_file_path,
530            Some("/uploads/convocations/conv-123.pdf".to_string())
531        );
532    }
533
534    #[test]
535    fn test_should_send_reminder() {
536        // Test case 1: Meeting in 20 days - should NOT send reminder (too early)
537        // Art. 3.87 §3: all types require 15 days notice, so 20 days is valid
538        let far_meeting_date = Utc::now() + Duration::days(20);
539        let mut convocation_far = Convocation::new(
540            Uuid::new_v4(),
541            Uuid::new_v4(),
542            Uuid::new_v4(),
543            ConvocationType::Extraordinary, // 15 days notice (same as all types per Art. 3.87 §3)
544            far_meeting_date,
545            "FR".to_string(),
546            Uuid::new_v4(),
547        )
548        .unwrap();
549
550        convocation_far
551            .mark_sent("/uploads/conv.pdf".to_string(), 30)
552            .unwrap();
553
554        // Should NOT send reminder yet (meeting is 20 days away, reminder threshold is 3 days)
555        assert!(!convocation_far.should_send_reminder());
556
557        // Test case 2: For a meeting within 3 days, we'd need to create it with proper notice
558        // and then wait. Since we can't time-travel in tests, we just verify the logic
559        // that reminders are sent within 3 days of meeting.
560        // The actual production code would check this daily via a cron job.
561    }
562
563    #[test]
564    fn test_opening_rate() {
565        let meeting_date = Utc::now() + Duration::days(20);
566        let mut convocation = Convocation::new(
567            Uuid::new_v4(),
568            Uuid::new_v4(),
569            Uuid::new_v4(),
570            ConvocationType::Ordinary,
571            meeting_date,
572            "FR".to_string(),
573            Uuid::new_v4(),
574        )
575        .unwrap();
576
577        convocation
578            .mark_sent("/uploads/conv.pdf".to_string(), 100)
579            .unwrap();
580        convocation.update_tracking_counts(75, 50, 10);
581
582        assert_eq!(convocation.opening_rate(), 75.0);
583        assert_eq!(convocation.attendance_rate(), 50.0);
584    }
585
586    #[test]
587    fn test_respects_legal_deadline() {
588        let meeting_date = Utc::now() + Duration::days(20);
589        let mut convocation = Convocation::new(
590            Uuid::new_v4(),
591            Uuid::new_v4(),
592            Uuid::new_v4(),
593            ConvocationType::Ordinary,
594            meeting_date,
595            "FR".to_string(),
596            Uuid::new_v4(),
597        )
598        .unwrap();
599
600        // Before sending but still within deadline (meeting J+20, minimum_send J+5)
601        assert!(convocation.respects_legal_deadline());
602
603        // After sending (now is before minimum_send_date so deadline respected)
604        convocation
605            .mark_sent("/uploads/conv.pdf".to_string(), 30)
606            .unwrap();
607        assert!(convocation.respects_legal_deadline());
608    }
609
610    #[test]
611    fn test_second_convocation_success() {
612        // 1ère AG dans 30 jours → 2e AG dans 50 jours (>15 jours après la 1ère)
613        let first_meeting_date = Utc::now() + Duration::days(30);
614        let second_meeting_date = Utc::now() + Duration::days(50);
615        let first_meeting_id = Uuid::new_v4();
616        let new_meeting_id = Uuid::new_v4();
617
618        let result = Convocation::new_second_convocation(
619            Uuid::new_v4(),
620            Uuid::new_v4(),
621            new_meeting_id,
622            first_meeting_id,
623            first_meeting_date,
624            second_meeting_date,
625            "FR".to_string(),
626            Uuid::new_v4(),
627        );
628
629        assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err());
630        let conv = result.unwrap();
631        assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
632        assert_eq!(conv.first_meeting_id, Some(first_meeting_id));
633        assert_eq!(conv.meeting_id, new_meeting_id);
634    }
635
636    #[test]
637    fn test_second_convocation_too_soon_fails() {
638        // 1ère AG dans 30 jours → 2e AG dans 40 jours (seulement 10 jours après → KO)
639        let first_meeting_date = Utc::now() + Duration::days(30);
640        let second_meeting_date = Utc::now() + Duration::days(40); // 10 jours seulement
641
642        let result = Convocation::new_second_convocation(
643            Uuid::new_v4(),
644            Uuid::new_v4(),
645            Uuid::new_v4(),
646            Uuid::new_v4(),
647            first_meeting_date,
648            second_meeting_date,
649            "FR".to_string(),
650            Uuid::new_v4(),
651        );
652
653        assert!(result.is_err());
654        assert!(result.unwrap_err().contains("15 days after"));
655    }
656
657    #[test]
658    fn test_second_convocation_exactly_15_days_ok() {
659        // 1ère AG dans 30 jours → 2e AG dans 45 jours (exactement 15 jours → OK)
660        let first_meeting_date = Utc::now() + Duration::days(30);
661        let second_meeting_date = Utc::now() + Duration::days(45);
662
663        let result = Convocation::new_second_convocation(
664            Uuid::new_v4(),
665            Uuid::new_v4(),
666            Uuid::new_v4(),
667            Uuid::new_v4(),
668            first_meeting_date,
669            second_meeting_date,
670            "FR".to_string(),
671            Uuid::new_v4(),
672        );
673
674        assert!(result.is_ok());
675        let conv = result.unwrap();
676        assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
677    }
678}