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, 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            async fn set_user_link(&self, owner_id: Uuid, user_id: Option<Uuid>) -> Result<bool, String>;
570        }
571    }
572
573    mock! {
574        BuildingRepo {}
575
576        #[async_trait]
577        impl BuildingRepository for BuildingRepo {
578            async fn create(&self, building: &Building) -> Result<Building, String>;
579            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
580            async fn find_all(&self) -> Result<Vec<Building>, String>;
581            async fn find_all_paginated(&self, page_request: &PageRequest, filters: &BuildingFilters) -> Result<(Vec<Building>, i64), String>;
582            async fn update(&self, building: &Building) -> Result<Building, String>;
583            async fn delete(&self, id: Uuid) -> Result<bool, String>;
584            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
585        }
586    }
587
588    mock! {
589        MeetingRepo {}
590
591        #[async_trait]
592        impl MeetingRepository for MeetingRepo {
593            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
594            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
595            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
596            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
597            async fn delete(&self, id: Uuid) -> Result<bool, String>;
598            async fn find_all_paginated(&self, page_request: &PageRequest, organization_id: Option<Uuid>) -> Result<(Vec<Meeting>, i64), String>;
599        }
600    }
601
602    // ---------------------------------------------------------------------------
603    // Helpers
604    // ---------------------------------------------------------------------------
605
606    /// Build a ConvocationUseCases with the given mocks, using defaults (no-op) for the rest.
607    fn make_use_cases(
608        conv_repo: MockConvRepo,
609        recip_repo: MockRecipientRepo,
610        owner_repo: MockOwnerRepo,
611        building_repo: MockBuildingRepo,
612        meeting_repo: MockMeetingRepo,
613    ) -> ConvocationUseCases {
614        ConvocationUseCases::new(
615            Arc::new(conv_repo),
616            Arc::new(recip_repo),
617            Arc::new(owner_repo),
618            Arc::new(building_repo),
619            Arc::new(meeting_repo),
620        )
621    }
622
623    /// Create a valid Convocation domain entity (meeting in 20 days, Ordinary type).
624    fn make_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
625        let meeting_date = Utc::now() + Duration::days(20);
626        Convocation::new(
627            org_id,
628            building_id,
629            meeting_id,
630            ConvocationType::Ordinary,
631            meeting_date,
632            "FR".to_string(),
633            Uuid::new_v4(),
634        )
635        .expect("helper should produce a valid convocation")
636    }
637
638    /// Create a valid Convocation that is already Sent (status=Sent, has recipients, etc.).
639    fn make_sent_convocation(org_id: Uuid, building_id: Uuid, meeting_id: Uuid) -> Convocation {
640        let mut conv = make_convocation(org_id, building_id, meeting_id);
641        conv.mark_sent("/tmp/conv.pdf".to_string(), 5).unwrap();
642        conv
643    }
644
645    /// Create a valid ConvocationRecipient (email already sent).
646    fn make_recipient(convocation_id: Uuid, owner_id: Uuid) -> ConvocationRecipient {
647        let mut r =
648            ConvocationRecipient::new(convocation_id, owner_id, "owner@example.com".to_string())
649                .unwrap();
650        r.mark_email_sent();
651        r
652    }
653
654    // ---------------------------------------------------------------------------
655    // Test 1: Create convocation with valid legal deadline (ordinary, 20 days)
656    // ---------------------------------------------------------------------------
657    #[tokio::test]
658    async fn test_create_convocation_ordinary_valid_deadline() {
659        let org_id = Uuid::new_v4();
660        let building_id = Uuid::new_v4();
661        let meeting_id = Uuid::new_v4();
662        let meeting_date = Utc::now() + Duration::days(20);
663
664        let mut conv_repo = MockConvRepo::new();
665        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
666
667        let uc = make_use_cases(
668            conv_repo,
669            MockRecipientRepo::new(),
670            MockOwnerRepo::new(),
671            MockBuildingRepo::new(),
672            MockMeetingRepo::new(),
673        );
674
675        let request = CreateConvocationRequest {
676            building_id,
677            meeting_id,
678            meeting_type: ConvocationType::Ordinary,
679            meeting_date,
680            language: "FR".to_string(),
681        };
682
683        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
684
685        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
686        let resp = result.unwrap();
687        assert_eq!(resp.status, ConvocationStatus::Draft);
688        assert_eq!(resp.language, "FR");
689        assert!(resp.respects_legal_deadline);
690    }
691
692    // ---------------------------------------------------------------------------
693    // Test 2: Create convocation violating legal deadline (only 5 days notice)
694    // Art. 3.87 §3 CC requires 15 days for Ordinary
695    // ---------------------------------------------------------------------------
696    #[tokio::test]
697    async fn test_create_convocation_violating_legal_deadline() {
698        let org_id = Uuid::new_v4();
699        let meeting_date = Utc::now() + Duration::days(5); // Only 5 days — too soon
700
701        let uc = make_use_cases(
702            MockConvRepo::new(),
703            MockRecipientRepo::new(),
704            MockOwnerRepo::new(),
705            MockBuildingRepo::new(),
706            MockMeetingRepo::new(),
707        );
708
709        let request = CreateConvocationRequest {
710            building_id: Uuid::new_v4(),
711            meeting_id: Uuid::new_v4(),
712            meeting_type: ConvocationType::Ordinary,
713            meeting_date,
714            language: "FR".to_string(),
715        };
716
717        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
718
719        assert!(result.is_err());
720        let err = result.unwrap_err();
721        assert!(
722            err.contains("Meeting date too soon"),
723            "Expected 'Meeting date too soon' error, got: {}",
724            err
725        );
726    }
727
728    // ---------------------------------------------------------------------------
729    // Test 3: Create extraordinary convocation with valid deadline (15 days)
730    // Art. 3.87 §3 CC: extraordinary also requires 15 days
731    // ---------------------------------------------------------------------------
732    #[tokio::test]
733    async fn test_create_convocation_extraordinary_valid_deadline() {
734        let org_id = Uuid::new_v4();
735        let meeting_date = Utc::now() + Duration::days(16); // 16 days — enough for extraordinary
736
737        let mut conv_repo = MockConvRepo::new();
738        conv_repo.expect_create().returning(|conv| Ok(conv.clone()));
739
740        let uc = make_use_cases(
741            conv_repo,
742            MockRecipientRepo::new(),
743            MockOwnerRepo::new(),
744            MockBuildingRepo::new(),
745            MockMeetingRepo::new(),
746        );
747
748        let request = CreateConvocationRequest {
749            building_id: Uuid::new_v4(),
750            meeting_id: Uuid::new_v4(),
751            meeting_type: ConvocationType::Extraordinary,
752            meeting_date,
753            language: "NL".to_string(),
754        };
755
756        let result = uc.create_convocation(org_id, request, Uuid::new_v4()).await;
757
758        assert!(result.is_ok());
759        let resp = result.unwrap();
760        assert_eq!(resp.language, "NL");
761    }
762
763    // ---------------------------------------------------------------------------
764    // Test 4: Schedule convocation (Draft -> Scheduled)
765    // ---------------------------------------------------------------------------
766    #[tokio::test]
767    async fn test_schedule_convocation_success() {
768        let org_id = Uuid::new_v4();
769        let building_id = Uuid::new_v4();
770        let meeting_id = Uuid::new_v4();
771        let conv = make_convocation(org_id, building_id, meeting_id);
772        let conv_id = conv.id;
773        let min_send_date = conv.minimum_send_date;
774
775        let mut conv_repo = MockConvRepo::new();
776        let conv_clone = conv.clone();
777        conv_repo
778            .expect_find_by_id()
779            .returning(move |_| Ok(Some(conv_clone.clone())));
780        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
781
782        let uc = make_use_cases(
783            conv_repo,
784            MockRecipientRepo::new(),
785            MockOwnerRepo::new(),
786            MockBuildingRepo::new(),
787            MockMeetingRepo::new(),
788        );
789
790        // Schedule to send before the minimum_send_date (valid)
791        let send_date = min_send_date - Duration::days(1);
792        let request = ScheduleConvocationRequest { send_date };
793
794        let result = uc.schedule_convocation(conv_id, request).await;
795
796        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
797        let resp = result.unwrap();
798        assert_eq!(resp.status, ConvocationStatus::Scheduled);
799        assert!(resp.scheduled_send_date.is_some());
800    }
801
802    // ---------------------------------------------------------------------------
803    // Test 5: Cancel convocation (Draft -> Cancelled)
804    // ---------------------------------------------------------------------------
805    #[tokio::test]
806    async fn test_cancel_convocation_success() {
807        let org_id = Uuid::new_v4();
808        let building_id = Uuid::new_v4();
809        let meeting_id = Uuid::new_v4();
810        let conv = make_convocation(org_id, building_id, meeting_id);
811        let conv_id = conv.id;
812
813        let mut conv_repo = MockConvRepo::new();
814        let conv_clone = conv.clone();
815        conv_repo
816            .expect_find_by_id()
817            .returning(move |_| Ok(Some(conv_clone.clone())));
818        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
819
820        let uc = make_use_cases(
821            conv_repo,
822            MockRecipientRepo::new(),
823            MockOwnerRepo::new(),
824            MockBuildingRepo::new(),
825            MockMeetingRepo::new(),
826        );
827
828        let result = uc.cancel_convocation(conv_id).await;
829
830        assert!(result.is_ok());
831        let resp = result.unwrap();
832        assert_eq!(resp.status, ConvocationStatus::Cancelled);
833    }
834
835    // ---------------------------------------------------------------------------
836    // Test 6: Cancel already-cancelled convocation -> error
837    // ---------------------------------------------------------------------------
838    #[tokio::test]
839    async fn test_cancel_convocation_already_cancelled_error() {
840        let org_id = Uuid::new_v4();
841        let building_id = Uuid::new_v4();
842        let meeting_id = Uuid::new_v4();
843        let mut conv = make_convocation(org_id, building_id, meeting_id);
844        conv.cancel().unwrap(); // Already cancelled
845        let conv_id = conv.id;
846
847        let mut conv_repo = MockConvRepo::new();
848        let conv_clone = conv.clone();
849        conv_repo
850            .expect_find_by_id()
851            .returning(move |_| Ok(Some(conv_clone.clone())));
852
853        let uc = make_use_cases(
854            conv_repo,
855            MockRecipientRepo::new(),
856            MockOwnerRepo::new(),
857            MockBuildingRepo::new(),
858            MockMeetingRepo::new(),
859        );
860
861        let result = uc.cancel_convocation(conv_id).await;
862
863        assert!(result.is_err());
864        assert!(result.unwrap_err().contains("already cancelled"));
865    }
866
867    // ---------------------------------------------------------------------------
868    // Test 7: Send reminders (J-3) marks recipients as reminder_sent
869    // ---------------------------------------------------------------------------
870    #[tokio::test]
871    async fn test_send_reminders_marks_recipients() {
872        let org_id = Uuid::new_v4();
873        let building_id = Uuid::new_v4();
874        let meeting_id = Uuid::new_v4();
875        let conv = make_sent_convocation(org_id, building_id, meeting_id);
876        let conv_id = conv.id;
877
878        let owner1_id = Uuid::new_v4();
879        let owner2_id = Uuid::new_v4();
880        let r1 = make_recipient(conv_id, owner1_id);
881        let r2 = make_recipient(conv_id, owner2_id);
882
883        let mut recip_repo = MockRecipientRepo::new();
884        let r1_clone = r1.clone();
885        let r2_clone = r2.clone();
886        recip_repo
887            .expect_find_needing_reminder()
888            .returning(move |_| Ok(vec![r1_clone.clone(), r2_clone.clone()]));
889        recip_repo.expect_update().returning(|r| Ok(r.clone()));
890
891        let mut conv_repo = MockConvRepo::new();
892        let conv_clone = conv.clone();
893        conv_repo
894            .expect_find_by_id()
895            .returning(move |_| Ok(Some(conv_clone.clone())));
896        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
897
898        let uc = make_use_cases(
899            conv_repo,
900            recip_repo,
901            MockOwnerRepo::new(),
902            MockBuildingRepo::new(),
903            MockMeetingRepo::new(),
904        );
905
906        let result = uc.send_reminders(conv_id).await;
907
908        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
909        let recipients = result.unwrap();
910        assert_eq!(recipients.len(), 2);
911        // After mark_reminder_sent, reminder_sent_at should be set
912        assert!(recipients[0].reminder_sent_at.is_some());
913        assert!(recipients[1].reminder_sent_at.is_some());
914    }
915
916    // ---------------------------------------------------------------------------
917    // Test 8: Track email opened updates convocation tracking counts
918    // ---------------------------------------------------------------------------
919    #[tokio::test]
920    async fn test_mark_email_opened_updates_tracking() {
921        let org_id = Uuid::new_v4();
922        let building_id = Uuid::new_v4();
923        let meeting_id = Uuid::new_v4();
924        let conv = make_sent_convocation(org_id, building_id, meeting_id);
925        let conv_id = conv.id;
926
927        let owner_id = Uuid::new_v4();
928        let recipient = make_recipient(conv_id, owner_id);
929        let recipient_id = recipient.id;
930
931        let mut recip_repo = MockRecipientRepo::new();
932        let recip_clone = recipient.clone();
933        recip_repo
934            .expect_find_by_id()
935            .returning(move |_| Ok(Some(recip_clone.clone())));
936        recip_repo.expect_update().returning(|r| Ok(r.clone()));
937        recip_repo
938            .expect_get_tracking_summary()
939            .returning(move |_| {
940                Ok(RecipientTrackingSummary {
941                    total_count: 5,
942                    opened_count: 3,
943                    will_attend_count: 2,
944                    will_not_attend_count: 1,
945                    attended_count: 0,
946                    did_not_attend_count: 0,
947                    pending_count: 2,
948                    failed_email_count: 0,
949                })
950            });
951
952        let mut conv_repo = MockConvRepo::new();
953        let conv_clone = conv.clone();
954        conv_repo
955            .expect_find_by_id()
956            .returning(move |_| Ok(Some(conv_clone.clone())));
957        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
958
959        let uc = make_use_cases(
960            conv_repo,
961            recip_repo,
962            MockOwnerRepo::new(),
963            MockBuildingRepo::new(),
964            MockMeetingRepo::new(),
965        );
966
967        let result = uc.mark_recipient_email_opened(recipient_id).await;
968
969        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
970        let resp = result.unwrap();
971        assert!(resp.has_opened_email);
972    }
973
974    // ---------------------------------------------------------------------------
975    // Test 9: Update attendance status (Pending -> WillAttend)
976    // ---------------------------------------------------------------------------
977    #[tokio::test]
978    async fn test_update_attendance_will_attend() {
979        let org_id = Uuid::new_v4();
980        let building_id = Uuid::new_v4();
981        let meeting_id = Uuid::new_v4();
982        let conv = make_sent_convocation(org_id, building_id, meeting_id);
983        let conv_id = conv.id;
984
985        let owner_id = Uuid::new_v4();
986        let recipient = make_recipient(conv_id, owner_id);
987        let recipient_id = recipient.id;
988
989        let mut recip_repo = MockRecipientRepo::new();
990        let recip_clone = recipient.clone();
991        recip_repo
992            .expect_find_by_id()
993            .returning(move |_| Ok(Some(recip_clone.clone())));
994        recip_repo.expect_update().returning(|r| Ok(r.clone()));
995        recip_repo
996            .expect_get_tracking_summary()
997            .returning(move |_| {
998                Ok(RecipientTrackingSummary {
999                    total_count: 5,
1000                    opened_count: 1,
1001                    will_attend_count: 1,
1002                    will_not_attend_count: 0,
1003                    attended_count: 0,
1004                    did_not_attend_count: 0,
1005                    pending_count: 4,
1006                    failed_email_count: 0,
1007                })
1008            });
1009
1010        let mut conv_repo = MockConvRepo::new();
1011        let conv_clone = conv.clone();
1012        conv_repo
1013            .expect_find_by_id()
1014            .returning(move |_| Ok(Some(conv_clone.clone())));
1015        conv_repo.expect_update().returning(|conv| Ok(conv.clone()));
1016
1017        let uc = make_use_cases(
1018            conv_repo,
1019            recip_repo,
1020            MockOwnerRepo::new(),
1021            MockBuildingRepo::new(),
1022            MockMeetingRepo::new(),
1023        );
1024
1025        let result = uc
1026            .update_recipient_attendance(recipient_id, AttendanceStatus::WillAttend)
1027            .await;
1028
1029        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1030        let resp = result.unwrap();
1031        assert_eq!(resp.attendance_status, AttendanceStatus::WillAttend);
1032        assert!(resp.has_confirmed_attendance);
1033    }
1034
1035    // ---------------------------------------------------------------------------
1036    // Test 10: Set proxy delegation (Belgian "procuration")
1037    // ---------------------------------------------------------------------------
1038    #[tokio::test]
1039    async fn test_set_proxy_delegation() {
1040        let conv_id = Uuid::new_v4();
1041        let owner_id = Uuid::new_v4();
1042        let proxy_owner_id = Uuid::new_v4();
1043        let recipient = make_recipient(conv_id, owner_id);
1044        let recipient_id = recipient.id;
1045
1046        let mut recip_repo = MockRecipientRepo::new();
1047        let recip_clone = recipient.clone();
1048        recip_repo
1049            .expect_find_by_id()
1050            .returning(move |_| Ok(Some(recip_clone.clone())));
1051        recip_repo.expect_update().returning(|r| Ok(r.clone()));
1052
1053        let uc = make_use_cases(
1054            MockConvRepo::new(),
1055            recip_repo,
1056            MockOwnerRepo::new(),
1057            MockBuildingRepo::new(),
1058            MockMeetingRepo::new(),
1059        );
1060
1061        let result = uc.set_recipient_proxy(recipient_id, proxy_owner_id).await;
1062
1063        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
1064        let resp = result.unwrap();
1065        assert_eq!(resp.proxy_owner_id, Some(proxy_owner_id));
1066    }
1067
1068    // ---------------------------------------------------------------------------
1069    // Test 11: Set proxy to self -> error ("Cannot delegate to self")
1070    // ---------------------------------------------------------------------------
1071    #[tokio::test]
1072    async fn test_set_proxy_to_self_error() {
1073        let conv_id = Uuid::new_v4();
1074        let owner_id = Uuid::new_v4();
1075        let recipient = make_recipient(conv_id, owner_id);
1076        let recipient_id = recipient.id;
1077        let self_owner_id = recipient.owner_id;
1078
1079        let mut recip_repo = MockRecipientRepo::new();
1080        let recip_clone = recipient.clone();
1081        recip_repo
1082            .expect_find_by_id()
1083            .returning(move |_| Ok(Some(recip_clone.clone())));
1084
1085        let uc = make_use_cases(
1086            MockConvRepo::new(),
1087            recip_repo,
1088            MockOwnerRepo::new(),
1089            MockBuildingRepo::new(),
1090            MockMeetingRepo::new(),
1091        );
1092
1093        let result = uc.set_recipient_proxy(recipient_id, self_owner_id).await;
1094
1095        assert!(result.is_err());
1096        assert!(result.unwrap_err().contains("Cannot delegate to self"));
1097    }
1098}