koprogo_api/application/use_cases/
convocation_use_cases.rs

1use crate::application::dto::{
2    ConvocationRecipientResponse, ConvocationResponse, CreateConvocationRequest,
3    RecipientTrackingSummaryResponse, ScheduleConvocationRequest, SendConvocationRequest,
4};
5use crate::application::ports::{
6    BuildingRepository, ConvocationRecipientRepository, ConvocationRepository, MeetingRepository,
7    OwnerRepository,
8};
9use crate::domain::entities::{AttendanceStatus, Convocation, ConvocationRecipient};
10use crate::domain::services::ConvocationExporter;
11use chrono::Utc;
12use std::sync::Arc;
13use uuid::Uuid;
14
15pub struct ConvocationUseCases {
16    convocation_repository: Arc<dyn ConvocationRepository>,
17    recipient_repository: Arc<dyn ConvocationRecipientRepository>,
18    owner_repository: Arc<dyn OwnerRepository>,
19    building_repository: Arc<dyn BuildingRepository>,
20    meeting_repository: Arc<dyn MeetingRepository>,
21}
22
23impl ConvocationUseCases {
24    pub fn new(
25        convocation_repository: Arc<dyn ConvocationRepository>,
26        recipient_repository: Arc<dyn ConvocationRecipientRepository>,
27        owner_repository: Arc<dyn OwnerRepository>,
28        building_repository: Arc<dyn BuildingRepository>,
29        meeting_repository: Arc<dyn MeetingRepository>,
30    ) -> Self {
31        Self {
32            convocation_repository,
33            recipient_repository,
34            owner_repository,
35            building_repository,
36            meeting_repository,
37        }
38    }
39
40    /// Create a new convocation
41    pub async fn create_convocation(
42        &self,
43        organization_id: Uuid,
44        request: CreateConvocationRequest,
45        created_by: Uuid,
46    ) -> Result<ConvocationResponse, String> {
47        // Create domain entity (validates legal deadline)
48        let convocation = Convocation::new(
49            organization_id,
50            request.building_id,
51            request.meeting_id,
52            request.meeting_type,
53            request.meeting_date,
54            request.language,
55            created_by,
56        )?;
57
58        let created = self.convocation_repository.create(&convocation).await?;
59
60        Ok(ConvocationResponse::from(created))
61    }
62
63    /// Get convocation by ID
64    pub async fn get_convocation(&self, id: Uuid) -> Result<ConvocationResponse, String> {
65        let convocation = self
66            .convocation_repository
67            .find_by_id(id)
68            .await?
69            .ok_or_else(|| format!("Convocation not found: {}", id))?;
70
71        Ok(ConvocationResponse::from(convocation))
72    }
73
74    /// Get convocation by meeting ID
75    pub async fn get_convocation_by_meeting(
76        &self,
77        meeting_id: Uuid,
78    ) -> Result<Option<ConvocationResponse>, String> {
79        let convocation = self
80            .convocation_repository
81            .find_by_meeting_id(meeting_id)
82            .await?;
83
84        Ok(convocation.map(ConvocationResponse::from))
85    }
86
87    /// List convocations for a building
88    pub async fn list_building_convocations(
89        &self,
90        building_id: Uuid,
91    ) -> Result<Vec<ConvocationResponse>, String> {
92        let convocations = self
93            .convocation_repository
94            .find_by_building(building_id)
95            .await?;
96
97        Ok(convocations
98            .into_iter()
99            .map(ConvocationResponse::from)
100            .collect())
101    }
102
103    /// List convocations for an organization
104    pub async fn list_organization_convocations(
105        &self,
106        organization_id: Uuid,
107    ) -> Result<Vec<ConvocationResponse>, String> {
108        let convocations = self
109            .convocation_repository
110            .find_by_organization(organization_id)
111            .await?;
112
113        Ok(convocations
114            .into_iter()
115            .map(ConvocationResponse::from)
116            .collect())
117    }
118
119    /// Schedule convocation to be sent at specific date
120    pub async fn schedule_convocation(
121        &self,
122        id: Uuid,
123        request: ScheduleConvocationRequest,
124    ) -> Result<ConvocationResponse, String> {
125        let mut convocation = self
126            .convocation_repository
127            .find_by_id(id)
128            .await?
129            .ok_or_else(|| format!("Convocation not found: {}", id))?;
130
131        convocation.schedule(request.send_date)?;
132
133        let updated = self.convocation_repository.update(&convocation).await?;
134
135        Ok(ConvocationResponse::from(updated))
136    }
137
138    /// Send convocation to owners (generates PDF, creates recipients, sends emails)
139    /// This would typically be called by a background job or email service
140    pub async fn send_convocation(
141        &self,
142        id: Uuid,
143        request: SendConvocationRequest,
144    ) -> Result<ConvocationResponse, String> {
145        let mut convocation = self
146            .convocation_repository
147            .find_by_id(id)
148            .await?
149            .ok_or_else(|| format!("Convocation not found: {}", id))?;
150
151        // Fetch building for PDF generation
152        let building = self
153            .building_repository
154            .find_by_id(convocation.building_id)
155            .await?
156            .ok_or_else(|| format!("Building not found: {}", convocation.building_id))?;
157
158        // Fetch meeting for PDF generation
159        let meeting = self
160            .meeting_repository
161            .find_by_id(convocation.meeting_id)
162            .await?
163            .ok_or_else(|| format!("Meeting not found: {}", convocation.meeting_id))?;
164
165        // Generate PDF
166        let pdf_bytes = ConvocationExporter::export_to_pdf(&building, &meeting, &convocation)
167            .map_err(|e| format!("Failed to generate PDF: {}", e))?;
168
169        // Save PDF to file
170        let upload_dir =
171            std::env::var("UPLOAD_DIR").unwrap_or_else(|_| "/tmp/koprogo-uploads".to_string());
172        let pdf_file_path = format!("{}/convocations/conv-{}.pdf", upload_dir, id);
173        ConvocationExporter::save_to_file(&pdf_bytes, &pdf_file_path)
174            .map_err(|e| format!("Failed to save PDF: {}", e))?;
175
176        // Fetch owner emails
177        let mut recipients = Vec::new();
178        for owner_id in &request.recipient_owner_ids {
179            let owner = self
180                .owner_repository
181                .find_by_id(*owner_id)
182                .await?
183                .ok_or_else(|| format!("Owner not found: {}", owner_id))?;
184
185            let mut recipient = ConvocationRecipient::new(id, *owner_id, owner.email)?;
186            recipient.mark_email_sent();
187            recipients.push(recipient);
188        }
189
190        // Create recipients in database (bulk insert)
191        let created_recipients = self.recipient_repository.create_many(&recipients).await?;
192
193        // Mark convocation as sent
194        convocation.mark_sent(pdf_file_path, created_recipients.len() as i32)?;
195
196        let updated = self.convocation_repository.update(&convocation).await?;
197
198        Ok(ConvocationResponse::from(updated))
199    }
200
201    /// Mark recipient email as sent
202    pub async fn mark_recipient_email_sent(
203        &self,
204        recipient_id: Uuid,
205    ) -> Result<ConvocationRecipientResponse, String> {
206        let mut recipient = self
207            .recipient_repository
208            .find_by_id(recipient_id)
209            .await?
210            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
211
212        recipient.mark_email_sent();
213
214        let updated = self.recipient_repository.update(&recipient).await?;
215
216        Ok(ConvocationRecipientResponse::from(updated))
217    }
218
219    /// Mark recipient email as opened (tracking pixel or link click)
220    pub async fn mark_recipient_email_opened(
221        &self,
222        recipient_id: Uuid,
223    ) -> Result<ConvocationRecipientResponse, String> {
224        let mut recipient = self
225            .recipient_repository
226            .find_by_id(recipient_id)
227            .await?
228            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
229
230        recipient.mark_email_opened()?;
231
232        let updated = self.recipient_repository.update(&recipient).await?;
233
234        // Update convocation tracking counts
235        self.update_convocation_tracking(recipient.convocation_id)
236            .await?;
237
238        Ok(ConvocationRecipientResponse::from(updated))
239    }
240
241    /// Update recipient attendance status
242    pub async fn update_recipient_attendance(
243        &self,
244        recipient_id: Uuid,
245        status: AttendanceStatus,
246    ) -> Result<ConvocationRecipientResponse, String> {
247        let mut recipient = self
248            .recipient_repository
249            .find_by_id(recipient_id)
250            .await?
251            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
252
253        recipient.update_attendance_status(status)?;
254
255        let updated = self.recipient_repository.update(&recipient).await?;
256
257        // Update convocation tracking counts
258        self.update_convocation_tracking(recipient.convocation_id)
259            .await?;
260
261        Ok(ConvocationRecipientResponse::from(updated))
262    }
263
264    /// Set proxy delegation for recipient
265    pub async fn set_recipient_proxy(
266        &self,
267        recipient_id: Uuid,
268        proxy_owner_id: Uuid,
269    ) -> Result<ConvocationRecipientResponse, String> {
270        let mut recipient = self
271            .recipient_repository
272            .find_by_id(recipient_id)
273            .await?
274            .ok_or_else(|| format!("Recipient not found: {}", recipient_id))?;
275
276        recipient.set_proxy(proxy_owner_id)?;
277
278        let updated = self.recipient_repository.update(&recipient).await?;
279
280        Ok(ConvocationRecipientResponse::from(updated))
281    }
282
283    /// Send reminders to recipients who haven't opened the convocation (J-3)
284    /// This would typically be called by a background job
285    pub async fn send_reminders(
286        &self,
287        convocation_id: Uuid,
288    ) -> Result<Vec<ConvocationRecipientResponse>, String> {
289        // Get recipients who need reminder
290        let recipients = self
291            .recipient_repository
292            .find_needing_reminder(convocation_id)
293            .await?;
294
295        let mut updated_recipients = Vec::new();
296
297        for mut recipient in recipients {
298            recipient.mark_reminder_sent()?;
299            let updated = self.recipient_repository.update(&recipient).await?;
300            updated_recipients.push(ConvocationRecipientResponse::from(updated));
301        }
302
303        // Mark convocation as reminder sent
304        if !updated_recipients.is_empty() {
305            let mut convocation = self
306                .convocation_repository
307                .find_by_id(convocation_id)
308                .await?
309                .ok_or_else(|| format!("Convocation not found: {}", convocation_id))?;
310
311            convocation.mark_reminder_sent()?;
312            self.convocation_repository.update(&convocation).await?;
313        }
314
315        Ok(updated_recipients)
316    }
317
318    /// Get tracking summary for convocation
319    pub async fn get_tracking_summary(
320        &self,
321        convocation_id: Uuid,
322    ) -> Result<RecipientTrackingSummaryResponse, String> {
323        let summary = self
324            .recipient_repository
325            .get_tracking_summary(convocation_id)
326            .await?;
327
328        Ok(RecipientTrackingSummaryResponse::new(
329            summary.total_count,
330            summary.opened_count,
331            summary.will_attend_count,
332            summary.will_not_attend_count,
333            summary.attended_count,
334            summary.did_not_attend_count,
335            summary.pending_count,
336            summary.failed_email_count,
337        ))
338    }
339
340    /// Get all recipients for a convocation
341    pub async fn list_convocation_recipients(
342        &self,
343        convocation_id: Uuid,
344    ) -> Result<Vec<ConvocationRecipientResponse>, String> {
345        let recipients = self
346            .recipient_repository
347            .find_by_convocation(convocation_id)
348            .await?;
349
350        Ok(recipients
351            .into_iter()
352            .map(ConvocationRecipientResponse::from)
353            .collect())
354    }
355
356    /// Cancel convocation
357    pub async fn cancel_convocation(&self, id: Uuid) -> Result<ConvocationResponse, String> {
358        let mut convocation = self
359            .convocation_repository
360            .find_by_id(id)
361            .await?
362            .ok_or_else(|| format!("Convocation not found: {}", id))?;
363
364        convocation.cancel()?;
365
366        let updated = self.convocation_repository.update(&convocation).await?;
367
368        Ok(ConvocationResponse::from(updated))
369    }
370
371    /// Delete convocation (and all recipients via CASCADE)
372    pub async fn delete_convocation(&self, id: Uuid) -> Result<bool, String> {
373        self.convocation_repository.delete(id).await
374    }
375
376    /// Process scheduled convocations (called by background job)
377    /// Returns list of convocations that were sent
378    pub async fn process_scheduled_convocations(&self) -> Result<Vec<ConvocationResponse>, String> {
379        let now = Utc::now();
380        let scheduled = self
381            .convocation_repository
382            .find_pending_scheduled(now)
383            .await?;
384
385        let mut sent = Vec::new();
386
387        for convocation in scheduled {
388            // This would trigger PDF generation and email sending
389            // For now, we just return the list that needs processing
390            sent.push(ConvocationResponse::from(convocation));
391        }
392
393        Ok(sent)
394    }
395
396    /// Process reminder sending (called by background job)
397    /// Returns list of convocations that had reminders sent
398    pub async fn process_reminder_sending(&self) -> Result<Vec<ConvocationResponse>, String> {
399        let now = Utc::now();
400        let needing_reminder = self
401            .convocation_repository
402            .find_needing_reminder(now)
403            .await?;
404
405        let mut processed = Vec::new();
406
407        for convocation in needing_reminder {
408            // Send reminders to recipients
409            self.send_reminders(convocation.id).await?;
410            processed.push(ConvocationResponse::from(convocation));
411        }
412
413        Ok(processed)
414    }
415
416    /// Schedule a second convocation after quorum not reached
417    /// Art. 3.87 §5 CC: "La deuxième assemblée délibère valablement quel que soit le nombre de présents."
418    ///
419    /// # Arguments
420    /// * `first_meeting_id` - ID of the first meeting where quorum was not reached
421    /// * `new_meeting_id` - ID of the new meeting scheduled for the second convocation
422    /// * `new_meeting_date` - Date of the second meeting (must be ≥15 days after first meeting)
423    /// * `language` - Language for the convocation (FR/NL/DE/EN)
424    /// * `created_by` - User ID creating the second convocation
425    ///
426    /// # Returns
427    /// Result with the created second convocation
428    pub async fn schedule_second_convocation(
429        &self,
430        organization_id: Uuid,
431        building_id: Uuid,
432        first_meeting_id: Uuid,
433        new_meeting_id: Uuid,
434        new_meeting_date: chrono::DateTime<chrono::Utc>,
435        language: String,
436        created_by: Uuid,
437    ) -> Result<ConvocationResponse, String> {
438        // Fetch the first meeting to get its meeting date
439        let first_meeting = self
440            .meeting_repository
441            .find_by_id(first_meeting_id)
442            .await?
443            .ok_or_else(|| format!("First meeting not found: {}", first_meeting_id))?;
444
445        // Create the second convocation using the domain entity constructor
446        // This validates that the second meeting is at least 15 days after the first
447        let second_convocation = Convocation::new_second_convocation(
448            organization_id,
449            building_id,
450            new_meeting_id,
451            first_meeting_id,
452            first_meeting.scheduled_date,
453            new_meeting_date,
454            language,
455            created_by,
456        )?;
457
458        let created = self
459            .convocation_repository
460            .create(&second_convocation)
461            .await?;
462
463        Ok(ConvocationResponse::from(created))
464    }
465
466    /// Internal helper: Update convocation tracking counts from recipients
467    async fn update_convocation_tracking(&self, convocation_id: Uuid) -> Result<(), String> {
468        let summary = self
469            .recipient_repository
470            .get_tracking_summary(convocation_id)
471            .await?;
472
473        let mut convocation = self
474            .convocation_repository
475            .find_by_id(convocation_id)
476            .await?
477            .ok_or_else(|| format!("Convocation not found: {}", convocation_id))?;
478
479        convocation.update_tracking_counts(
480            summary.opened_count as i32,
481            summary.will_attend_count as i32,
482            summary.will_not_attend_count as i32,
483        );
484
485        self.convocation_repository.update(&convocation).await?;
486
487        Ok(())
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494    use crate::application::dto::{BuildingFilters, OwnerFilters, PageRequest};
495    use crate::application::ports::{
496        ConvocationRecipientRepository, ConvocationRepository, RecipientTrackingSummary,
497    };
498    use crate::domain::entities::{
499        AttendanceStatus, Building, Convocation, ConvocationRecipient, ConvocationStatus,
500        ConvocationType, Meeting, MeetingType, Owner,
501    };
502    use async_trait::async_trait;
503    use chrono::{Duration, Utc};
504    use mockall::mock;
505    use std::sync::Arc;
506    use uuid::Uuid;
507
508    // ---------------------------------------------------------------------------
509    // Mock definitions using mockall::mock!
510    // ---------------------------------------------------------------------------
511
512    mock! {
513        ConvRepo {}
514
515        #[async_trait]
516        impl ConvocationRepository for ConvRepo {
517            async fn create(&self, convocation: &Convocation) -> Result<Convocation, String>;
518            async fn find_by_id(&self, id: Uuid) -> Result<Option<Convocation>, String>;
519            async fn find_by_meeting_id(&self, meeting_id: Uuid) -> Result<Option<Convocation>, String>;
520            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Convocation>, String>;
521            async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<Convocation>, String>;
522            async fn find_by_status(&self, organization_id: Uuid, status: ConvocationStatus) -> Result<Vec<Convocation>, String>;
523            async fn find_pending_scheduled(&self, now: chrono::DateTime<Utc>) -> Result<Vec<Convocation>, String>;
524            async fn find_needing_reminder(&self, now: chrono::DateTime<Utc>) -> Result<Vec<Convocation>, String>;
525            async fn update(&self, convocation: &Convocation) -> Result<Convocation, String>;
526            async fn delete(&self, id: Uuid) -> Result<bool, String>;
527            async fn count_by_building(&self, building_id: Uuid) -> Result<i64, String>;
528            async fn count_by_status(&self, organization_id: Uuid, status: ConvocationStatus) -> Result<i64, String>;
529        }
530    }
531
532    mock! {
533        RecipientRepo {}
534
535        #[async_trait]
536        impl ConvocationRecipientRepository for RecipientRepo {
537            async fn create(&self, recipient: &ConvocationRecipient) -> Result<ConvocationRecipient, String>;
538            async fn create_many(&self, recipients: &[ConvocationRecipient]) -> Result<Vec<ConvocationRecipient>, String>;
539            async fn find_by_id(&self, id: Uuid) -> Result<Option<ConvocationRecipient>, String>;
540            async fn find_by_convocation(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
541            async fn find_by_convocation_and_owner(&self, convocation_id: Uuid, owner_id: Uuid) -> Result<Option<ConvocationRecipient>, String>;
542            async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
543            async fn find_by_attendance_status(&self, convocation_id: Uuid, status: AttendanceStatus) -> Result<Vec<ConvocationRecipient>, String>;
544            async fn find_needing_reminder(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
545            async fn find_failed_emails(&self, convocation_id: Uuid) -> Result<Vec<ConvocationRecipient>, String>;
546            async fn update(&self, recipient: &ConvocationRecipient) -> Result<ConvocationRecipient, String>;
547            async fn delete(&self, id: Uuid) -> Result<bool, String>;
548            async fn count_by_convocation(&self, convocation_id: Uuid) -> Result<i64, String>;
549            async fn count_opened(&self, convocation_id: Uuid) -> Result<i64, String>;
550            async fn count_by_attendance_status(&self, convocation_id: Uuid, status: AttendanceStatus) -> Result<i64, String>;
551            async fn get_tracking_summary(&self, convocation_id: Uuid) -> Result<RecipientTrackingSummary, String>;
552        }
553    }
554
555    mock! {
556        OwnerRepo {}
557
558        #[async_trait]
559        impl OwnerRepository for OwnerRepo {
560            async fn create(&self, owner: &Owner) -> Result<Owner, String>;
561            async fn find_by_id(&self, id: Uuid) -> Result<Option<Owner>, String>;
562            async fn find_by_user_id(&self, user_id: Uuid) -> Result<Option<Owner>, String>;
563            async fn find_by_user_id_and_organization(&self, user_id: Uuid, organization_id: Uuid) -> Result<Option<Owner>, String>;
564            async fn find_by_email(&self, email: &str) -> Result<Option<Owner>, String>;
565            async fn find_all(&self) -> Result<Vec<Owner>, String>;
566            async fn find_all_paginated(&self, page_request: &PageRequest, filters: &OwnerFilters) -> Result<(Vec<Owner>, i64), String>;
567            async fn update(&self, owner: &Owner) -> Result<Owner, String>;
568            async fn delete(&self, id: Uuid) -> Result<bool, String>;
569        }
570    }
571
572    mock! {
573        BuildingRepo {}
574
575        #[async_trait]
576        impl BuildingRepository for BuildingRepo {
577            async fn create(&self, building: &Building) -> Result<Building, String>;
578            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
579            async fn find_all(&self) -> Result<Vec<Building>, String>;
580            async fn find_all_paginated(&self, page_request: &PageRequest, filters: &BuildingFilters) -> Result<(Vec<Building>, i64), String>;
581            async fn update(&self, building: &Building) -> Result<Building, String>;
582            async fn delete(&self, id: Uuid) -> Result<bool, String>;
583            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
584        }
585    }
586
587    mock! {
588        MeetingRepo {}
589
590        #[async_trait]
591        impl MeetingRepository for MeetingRepo {
592            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
593            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
594            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
595            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
596            async fn delete(&self, id: Uuid) -> Result<bool, String>;
597            async fn find_all_paginated(&self, page_request: &PageRequest, organization_id: Option<Uuid>) -> Result<(Vec<Meeting>, i64), String>;
598        }
599    }
600
601    // ---------------------------------------------------------------------------
602    // Helpers
603    // ---------------------------------------------------------------------------
604
605    /// Build a ConvocationUseCases with the given mocks, using defaults (no-op) for the rest.
606    fn make_use_cases(
607        conv_repo: MockConvRepo,
608        recip_repo: MockRecipientRepo,
609        owner_repo: MockOwnerRepo,
610        building_repo: MockBuildingRepo,
611        meeting_repo: MockMeetingRepo,
612    ) -> ConvocationUseCases {
613        ConvocationUseCases::new(
614            Arc::new(conv_repo),
615            Arc::new(recip_repo),
616            Arc::new(owner_repo),
617            Arc::new(building_repo),
618            Arc::new(meeting_repo),
619        )
620    }
621
622    /// Create a valid Convocation domain entity (meeting in 20 days, Ordinary type).
623    fn make_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
624        let meeting_date = Utc::now() + Duration::days(20);
625        Convocation::new(
626            org_id,
627            building_id,
628            meeting_id,
629            ConvocationType::Ordinary,
630            meeting_date,
631            "FR".to_string(),
632            Uuid::new_v4(),
633        )
634        .expect("helper should produce a valid convocation")
635    }
636
637    /// Create a valid Convocation that is already Sent (status=Sent, has recipients, etc.).
638    fn make_sent_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
639        let mut conv = make_convocation(org_id, building_id, meeting_id);
640        conv.mark_sent("/tmp/conv.pdf".to_string(), 5).unwrap();
641        conv
642    }
643
644    /// Create a valid ConvocationRecipient (email already sent).
645    fn make_recipient(convocation_id: Uuid, owner_id: Uuid) -> ConvocationRecipient {
646        let mut r =
647            ConvocationRecipient::new(convocation_id, owner_id, "owner@example.com".to_string())
648                .unwrap();
649        r.mark_email_sent();
650        r
651    }
652
653    // ---------------------------------------------------------------------------
654    // Test 1: Create convocation with valid legal deadline (ordinary, 20 days)
655    // ---------------------------------------------------------------------------
656    #[tokio::test]
657    async fn test_create_convocation_ordinary_valid_deadline() {
658        let org_id = Uuid::new_v4();
659        let building_id = Uuid::new_v4();
660        let meeting_id = Uuid::new_v4();
661        let meeting_date = Utc::now() + Duration::days(20);
662
663        let mut conv_repo = MockConvRepo::new();
664        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
665
666        let uc = make_use_cases(
667            conv_repo,
668            MockRecipientRepo::new(),
669            MockOwnerRepo::new(),
670            MockBuildingRepo::new(),
671            MockMeetingRepo::new(),
672        );
673
674        let request = CreateConvocationRequest {
675            building_id,
676            meeting_id,
677            meeting_type: ConvocationType::Ordinary,
678            meeting_date,
679            language: "FR".to_string(),
680        };
681
682        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
683
684        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
685        let resp = result.unwrap();
686        assert_eq!(resp.status, ConvocationStatus::Draft);
687        assert_eq!(resp.language, "FR");
688        assert!(resp.respects_legal_deadline);
689    }
690
691    // ---------------------------------------------------------------------------
692    // Test 2: Create convocation violating legal deadline (only 5 days notice)
693    // Art. 3.87 §3 CC requires 15 days for Ordinary
694    // ---------------------------------------------------------------------------
695    #[tokio::test]
696    async fn test_create_convocation_violating_legal_deadline() {
697        let org_id = Uuid::new_v4();
698        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days — too soon
699
700        let uc = make_use_cases(
701            MockConvRepo::new(),
702            MockRecipientRepo::new(),
703            MockOwnerRepo::new(),
704            MockBuildingRepo::new(),
705            MockMeetingRepo::new(),
706        );
707
708        let request = CreateConvocationRequest {
709            building_id: Uuid::new_v4(),
710            meeting_id: Uuid::new_v4(),
711            meeting_type: ConvocationType::Ordinary,
712            meeting_date,
713            language: "FR".to_string(),
714        };
715
716        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
717
718        assert!(result.is_err());
719        let err = result.unwrap_err();
720        assert!(
721            err.contains("Meeting date too soon"),
722            "Expected 'Meeting date too soon' error, got: {}",
723            err
724        );
725    }
726
727    // ---------------------------------------------------------------------------
728    // Test 3: Create extraordinary convocation with valid deadline (15 days)
729    // Art. 3.87 §3 CC: extraordinary also requires 15 days
730    // ---------------------------------------------------------------------------
731    #[tokio::test]
732    async fn test_create_convocation_extraordinary_valid_deadline() {
733        let org_id = Uuid::new_v4();
734        let meeting_date = Utc::now() + Duration::days(16); // 16 days — enough for extraordinary
735
736        let mut conv_repo = MockConvRepo::new();
737        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
738
739        let uc = make_use_cases(
740            conv_repo,
741            MockRecipientRepo::new(),
742            MockOwnerRepo::new(),
743            MockBuildingRepo::new(),
744            MockMeetingRepo::new(),
745        );
746
747        let request = CreateConvocationRequest {
748            building_id: Uuid::new_v4(),
749            meeting_id: Uuid::new_v4(),
750            meeting_type: ConvocationType::Extraordinary,
751            meeting_date,
752            language: "NL".to_string(),
753        };
754
755        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
756
757        assert!(result.is_ok());
758        let resp = result.unwrap();
759        assert_eq!(resp.language, "NL");
760    }
761
762    // ---------------------------------------------------------------------------
763    // Test 4: Schedule convocation (Draft -> Scheduled)
764    // ---------------------------------------------------------------------------
765    #[tokio::test]
766    async fn test_schedule_convocation_success() {
767        let org_id = Uuid::new_v4();
768        let building_id = Uuid::new_v4();
769        let meeting_id = Uuid::new_v4();
770        let conv = make_convocation(org_id, building_id, meeting_id);
771        let conv_id = conv.id;
772        let min_send_date = conv.minimum_send_date;
773
774        let mut conv_repo = MockConvRepo::new();
775        let conv_clone = conv.clone();
776        conv_repo
777            .expect_find_by_id()
778            .returning(move |_| Ok(Some(conv_clone.clone())));
779        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
780
781        let uc = make_use_cases(
782            conv_repo,
783            MockRecipientRepo::new(),
784            MockOwnerRepo::new(),
785            MockBuildingRepo::new(),
786            MockMeetingRepo::new(),
787        );
788
789        // Schedule to send before the minimum_send_date (valid)
790        let send_date = min_send_date - Duration::days(1);
791        let request = ScheduleConvocationRequest { send_date };
792
793        let result = uc.schedule_convocation(conv_id, request).await;
794
795        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
796        let resp = result.unwrap();
797        assert_eq!(resp.status, ConvocationStatus::Scheduled);
798        assert!(resp.scheduled_send_date.is_some());
799    }
800
801    // ---------------------------------------------------------------------------
802    // Test 5: Cancel convocation (Draft -> Cancelled)
803    // ---------------------------------------------------------------------------
804    #[tokio::test]
805    async fn test_cancel_convocation_success() {
806        let org_id = Uuid::new_v4();
807        let building_id = Uuid::new_v4();
808        let meeting_id = Uuid::new_v4();
809        let conv = make_convocation(org_id, building_id, meeting_id);
810        let conv_id = conv.id;
811
812        let mut conv_repo = MockConvRepo::new();
813        let conv_clone = conv.clone();
814        conv_repo
815            .expect_find_by_id()
816            .returning(move |_| Ok(Some(conv_clone.clone())));
817        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
818
819        let uc = make_use_cases(
820            conv_repo,
821            MockRecipientRepo::new(),
822            MockOwnerRepo::new(),
823            MockBuildingRepo::new(),
824            MockMeetingRepo::new(),
825        );
826
827        let result = uc.cancel_convocation(conv_id).await;
828
829        assert!(result.is_ok());
830        let resp = result.unwrap();
831        assert_eq!(resp.status, ConvocationStatus::Cancelled);
832    }
833
834    // ---------------------------------------------------------------------------
835    // Test 6: Cancel already-cancelled convocation -> error
836    // ---------------------------------------------------------------------------
837    #[tokio::test]
838    async fn test_cancel_convocation_already_cancelled_error() {
839        let org_id = Uuid::new_v4();
840        let building_id = Uuid::new_v4();
841        let meeting_id = Uuid::new_v4();
842        let mut conv = make_convocation(org_id, building_id, meeting_id);
843        conv.cancel().unwrap(); // Already cancelled
844        let conv_id = conv.id;
845
846        let mut conv_repo = MockConvRepo::new();
847        let conv_clone = conv.clone();
848        conv_repo
849            .expect_find_by_id()
850            .returning(move |_| Ok(Some(conv_clone.clone())));
851
852        let uc = make_use_cases(
853            conv_repo,
854            MockRecipientRepo::new(),
855            MockOwnerRepo::new(),
856            MockBuildingRepo::new(),
857            MockMeetingRepo::new(),
858        );
859
860        let result = uc.cancel_convocation(conv_id).await;
861
862        assert!(result.is_err());
863        assert!(result.unwrap_err().contains("already cancelled"));
864    }
865
866    // ---------------------------------------------------------------------------
867    // Test 7: Send reminders (J-3) marks recipients as reminder_sent
868    // ---------------------------------------------------------------------------
869    #[tokio::test]
870    async fn test_send_reminders_marks_recipients() {
871        let org_id = Uuid::new_v4();
872        let building_id = Uuid::new_v4();
873        let meeting_id = Uuid::new_v4();
874        let conv = make_sent_convocation(org_id, building_id, meeting_id);
875        let conv_id = conv.id;
876
877        let owner1_id = Uuid::new_v4();
878        let owner2_id = Uuid::new_v4();
879        let r1 = make_recipient(conv_id, owner1_id);
880        let r2 = make_recipient(conv_id, owner2_id);
881
882        let mut recip_repo = MockRecipientRepo::new();
883        let r1_clone = r1.clone();
884        let r2_clone = r2.clone();
885        recip_repo
886            .expect_find_needing_reminder()
887            .returning(move |_| Ok(vec![r1_clone.clone(), r2_clone.clone()]));
888        recip_repo.expect_update().returning(|r| Ok(r.clone()));
889
890        let mut conv_repo = MockConvRepo::new();
891        let conv_clone = conv.clone();
892        conv_repo
893            .expect_find_by_id()
894            .returning(move |_| Ok(Some(conv_clone.clone())));
895        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
896
897        let uc = make_use_cases(
898            conv_repo,
899            recip_repo,
900            MockOwnerRepo::new(),
901            MockBuildingRepo::new(),
902            MockMeetingRepo::new(),
903        );
904
905        let result = uc.send_reminders(conv_id).await;
906
907        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
908        let recipients = result.unwrap();
909        assert_eq!(recipients.len(), 2);
910        // After mark_reminder_sent, reminder_sent_at should be set
911        assert!(recipients[0].reminder_sent_at.is_some());
912        assert!(recipients[1].reminder_sent_at.is_some());
913    }
914
915    // ---------------------------------------------------------------------------
916    // Test 8: Track email opened updates convocation tracking counts
917    // ---------------------------------------------------------------------------
918    #[tokio::test]
919    async fn test_mark_email_opened_updates_tracking() {
920        let org_id = Uuid::new_v4();
921        let building_id = Uuid::new_v4();
922        let meeting_id = Uuid::new_v4();
923        let conv = make_sent_convocation(org_id, building_id, meeting_id);
924        let conv_id = conv.id;
925
926        let owner_id = Uuid::new_v4();
927        let recipient = make_recipient(conv_id, owner_id);
928        let recipient_id = recipient.id;
929
930        let mut recip_repo = MockRecipientRepo::new();
931        let recip_clone = recipient.clone();
932        recip_repo
933            .expect_find_by_id()
934            .returning(move |_| Ok(Some(recip_clone.clone())));
935        recip_repo.expect_update().returning(|r| Ok(r.clone()));
936        recip_repo
937            .expect_get_tracking_summary()
938            .returning(move |_| {
939                Ok(RecipientTrackingSummary {
940                    total_count: 5,
941                    opened_count: 3,
942                    will_attend_count: 2,
943                    will_not_attend_count: 1,
944                    attended_count: 0,
945                    did_not_attend_count: 0,
946                    pending_count: 2,
947                    failed_email_count: 0,
948                })
949            });
950
951        let mut conv_repo = MockConvRepo::new();
952        let conv_clone = conv.clone();
953        conv_repo
954            .expect_find_by_id()
955            .returning(move |_| Ok(Some(conv_clone.clone())));
956        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
957
958        let uc = make_use_cases(
959            conv_repo,
960            recip_repo,
961            MockOwnerRepo::new(),
962            MockBuildingRepo::new(),
963            MockMeetingRepo::new(),
964        );
965
966        let result = uc.mark_recipient_email_opened(recipient_id).await;
967
968        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
969        let resp = result.unwrap();
970        assert!(resp.has_opened_email);
971    }
972
973    // ---------------------------------------------------------------------------
974    // Test 9: Update attendance status (Pending -> WillAttend)
975    // ---------------------------------------------------------------------------
976    #[tokio::test]
977    async fn test_update_attendance_will_attend() {
978        let org_id = Uuid::new_v4();
979        let building_id = Uuid::new_v4();
980        let meeting_id = Uuid::new_v4();
981        let conv = make_sent_convocation(org_id, building_id, meeting_id);
982        let conv_id = conv.id;
983
984        let owner_id = Uuid::new_v4();
985        let recipient = make_recipient(conv_id, owner_id);
986        let recipient_id = recipient.id;
987
988        let mut recip_repo = MockRecipientRepo::new();
989        let recip_clone = recipient.clone();
990        recip_repo
991            .expect_find_by_id()
992            .returning(move |_| Ok(Some(recip_clone.clone())));
993        recip_repo.expect_update().returning(|r| Ok(r.clone()));
994        recip_repo
995            .expect_get_tracking_summary()
996            .returning(move |_| {
997                Ok(RecipientTrackingSummary {
998                    total_count: 5,
999                    opened_count: 1,
1000                    will_attend_count: 1,
1001                    will_not_attend_count: 0,
1002                    attended_count: 0,
1003                    did_not_attend_count: 0,
1004                    pending_count: 4,
1005                    failed_email_count: 0,
1006                })
1007            });
1008
1009        let mut conv_repo = MockConvRepo::new();
1010        let conv_clone = conv.clone();
1011        conv_repo
1012            .expect_find_by_id()
1013            .returning(move |_| Ok(Some(conv_clone.clone())));
1014        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1015
1016        let uc = make_use_cases(
1017            conv_repo,
1018            recip_repo,
1019            MockOwnerRepo::new(),
1020            MockBuildingRepo::new(),
1021            MockMeetingRepo::new(),
1022        );
1023
1024        let result = uc
1025            .update_recipient_attendance(recipient_id, AttendanceStatus::WillAttend)
1026            .await;
1027
1028        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1029        let resp = result.unwrap();
1030        assert_eq!(resp.attendance_status, AttendanceStatus::WillAttend);
1031        assert!(resp.has_confirmed_attendance);
1032    }
1033
1034    // ---------------------------------------------------------------------------
1035    // Test 10: Set proxy delegation (Belgian "procuration")
1036    // ---------------------------------------------------------------------------
1037    #[tokio::test]
1038    async fn test_set_proxy_delegation() {
1039        let conv_id = Uuid::new_v4();
1040        let owner_id = Uuid::new_v4();
1041        let proxy_owner_id = Uuid::new_v4();
1042        let recipient = make_recipient(conv_id, owner_id);
1043        let recipient_id = recipient.id;
1044
1045        let mut recip_repo = MockRecipientRepo::new();
1046        let recip_clone = recipient.clone();
1047        recip_repo
1048            .expect_find_by_id()
1049            .returning(move |_| Ok(Some(recip_clone.clone())));
1050        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1051
1052        let uc = make_use_cases(
1053            MockConvRepo::new(),
1054            recip_repo,
1055            MockOwnerRepo::new(),
1056            MockBuildingRepo::new(),
1057            MockMeetingRepo::new(),
1058        );
1059
1060        let result = uc.set_recipient_proxy(recipient_id, proxy_owner_id).await;
1061
1062        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1063        let resp = result.unwrap();
1064        assert_eq!(resp.proxy_owner_id, Some(proxy_owner_id));
1065    }
1066
1067    // ---------------------------------------------------------------------------
1068    // Test 11: Set proxy to self -> error ("Cannot delegate to self")
1069    // ---------------------------------------------------------------------------
1070    #[tokio::test]
1071    async fn test_set_proxy_to_self_error() {
1072        let conv_id = Uuid::new_v4();
1073        let owner_id = Uuid::new_v4();
1074        let recipient = make_recipient(conv_id, owner_id);
1075        let recipient_id = recipient.id;
1076        let self_owner_id = recipient.owner_id;
1077
1078        let mut recip_repo = MockRecipientRepo::new();
1079        let recip_clone = recipient.clone();
1080        recip_repo
1081            .expect_find_by_id()
1082            .returning(move |_| Ok(Some(recip_clone.clone())));
1083
1084        let uc = make_use_cases(
1085            MockConvRepo::new(),
1086            recip_repo,
1087            MockOwnerRepo::new(),
1088            MockBuildingRepo::new(),
1089            MockMeetingRepo::new(),
1090        );
1091
1092        let result = uc.set_recipient_proxy(recipient_id, self_owner_id).await;
1093
1094        assert!(result.is_err());
1095        assert!(result.unwrap_err().contains("Cannot delegate to self"));
1096    }
1097}