1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
8pub enum ConvocationType {
9 Ordinary,
11 Extraordinary,
13 SecondConvocation,
15}
16
17impl ConvocationType {
18 pub fn minimum_notice_days(&self) -> i64 {
24 match self {
29 ConvocationType::Ordinary
30 | ConvocationType::Extraordinary
31 | ConvocationType::SecondConvocation => 15,
32 }
33 }
34
35 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, utoipa::ToSchema)]
57pub enum ConvocationStatus {
58 Draft,
60 Scheduled,
62 Sent,
64 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#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Convocation {
97 pub id: Uuid,
98
99 pub acp_id: Uuid,
106
107 pub organization_id: Uuid,
109 pub building_id: Uuid,
110 pub meeting_id: Uuid,
111 pub meeting_type: ConvocationType,
112 pub meeting_date: DateTime<Utc>,
113 pub status: ConvocationStatus,
114
115 pub first_meeting_id: Option<Uuid>,
117
118 pub no_quorum_required: bool,
121
122 pub minimum_send_date: DateTime<Utc>, pub actual_send_date: Option<DateTime<Utc>>, pub scheduled_send_date: Option<DateTime<Utc>>, pub pdf_file_path: Option<String>, pub language: String, pub total_recipients: i32,
133 pub opened_count: i32,
134 pub will_attend_count: i32,
135 pub will_not_attend_count: i32,
136
137 pub reminder_sent_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>,
142 pub updated_at: DateTime<Utc>,
143 pub created_by: Uuid,
144}
145
146impl Convocation {
147 pub fn new(
161 acp_id: Uuid,
162 organization_id: Uuid,
163 building_id: Uuid,
164 meeting_id: Uuid,
165 meeting_type: ConvocationType,
166 meeting_date: DateTime<Utc>,
167 language: String,
168 created_by: Uuid,
169 ) -> Result<Self, String> {
170 if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
172 return Err(format!(
173 "Invalid language '{}'. Must be FR, NL, DE, or EN",
174 language
175 ));
176 }
177
178 let minimum_notice_days = meeting_type.minimum_notice_days();
180 let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
181
182 let now = Utc::now();
184 if minimum_send_date < now {
185 return Err(format!(
197 "Art. 3.87 § 3 : une assemblée {} exige un préavis de {} jours. \
198 La convocation aurait dû partir au plus tard le {}. \
199 Reportez l'assemblée à une date plus lointaine.",
200 match meeting_type {
201 ConvocationType::Ordinary => "ordinaire",
202 ConvocationType::Extraordinary => "extraordinaire",
203 ConvocationType::SecondConvocation => "sur seconde convocation",
204 },
205 minimum_notice_days,
206 minimum_send_date.format("%d/%m/%Y à %H:%M")
207 ));
208 }
209
210 Ok(Self {
211 id: Uuid::new_v4(),
212 acp_id,
213 organization_id,
214 building_id,
215 meeting_id,
216 meeting_type,
217 meeting_date,
218 status: ConvocationStatus::Draft,
219 first_meeting_id: None,
220 no_quorum_required: false, minimum_send_date,
222 actual_send_date: None,
223 scheduled_send_date: None,
224 pdf_file_path: None,
225 language: language.to_uppercase(),
226 total_recipients: 0,
227 opened_count: 0,
228 will_attend_count: 0,
229 will_not_attend_count: 0,
230 reminder_sent_at: None,
231 created_at: now,
232 updated_at: now,
233 created_by,
234 })
235 }
236
237 pub fn new_second_convocation(
245 acp_id: Uuid,
246 organization_id: Uuid,
247 building_id: Uuid,
248 new_meeting_id: Uuid,
249 first_meeting_id: Uuid,
250 first_meeting_date: DateTime<Utc>,
251 new_meeting_date: DateTime<Utc>,
252 language: String,
253 created_by: Uuid,
254 ) -> Result<Self, String> {
255 let min_second_date = first_meeting_date + Duration::days(15);
262 if new_meeting_date < min_second_date {
263 return Err(format!(
264 "Art. 3.87 § 3 : la seconde assemblée doit se tenir au moins 15 jours \
265 après la première (tenue le {}). Date proposée : {}. \
266 Reportez la seconde assemblée au {} ou plus tard.",
267 first_meeting_date.format("%d/%m/%Y"),
268 new_meeting_date.format("%d/%m/%Y"),
269 min_second_date.format("%d/%m/%Y")
270 ));
271 }
272
273 let mut convocation = Self::new(
274 acp_id,
275 organization_id,
276 building_id,
277 new_meeting_id,
278 ConvocationType::SecondConvocation,
279 new_meeting_date,
280 language,
281 created_by,
282 )?;
283
284 convocation.first_meeting_id = Some(first_meeting_id);
285 convocation.no_quorum_required = true;
287 Ok(convocation)
288 }
289
290 pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
292 if self.status != ConvocationStatus::Draft {
293 return Err(format!(
294 "Cannot schedule convocation in status '{:?}'. Must be Draft",
295 self.status
296 ));
297 }
298
299 if send_date > self.minimum_send_date {
301 return Err(format!(
302 "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
303 send_date.format("%Y-%m-%d %H:%M"),
304 self.minimum_send_date.format("%Y-%m-%d %H:%M")
305 ));
306 }
307
308 self.scheduled_send_date = Some(send_date);
309 self.status = ConvocationStatus::Scheduled;
310 self.updated_at = Utc::now();
311 Ok(())
312 }
313
314 pub fn mark_sent(
316 &mut self,
317 pdf_file_path: String,
318 total_recipients: i32,
319 ) -> Result<(), String> {
320 if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
321 return Err(format!(
322 "Cannot send convocation in status '{:?}'",
323 self.status
324 ));
325 }
326
327 if total_recipients <= 0 {
328 return Err("Total recipients must be greater than 0".to_string());
329 }
330
331 self.status = ConvocationStatus::Sent;
332 self.actual_send_date = Some(Utc::now());
333 self.pdf_file_path = Some(pdf_file_path);
334 self.total_recipients = total_recipients;
335 self.updated_at = Utc::now();
336 Ok(())
337 }
338
339 pub fn cancel(&mut self) -> Result<(), String> {
341 if self.status == ConvocationStatus::Cancelled {
342 return Err("Convocation is already cancelled".to_string());
343 }
344
345 self.status = ConvocationStatus::Cancelled;
346 self.updated_at = Utc::now();
347 Ok(())
348 }
349
350 pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
352 if self.status != ConvocationStatus::Sent {
353 return Err("Cannot send reminder for unsent convocation".to_string());
354 }
355
356 self.reminder_sent_at = Some(Utc::now());
357 self.updated_at = Utc::now();
358 Ok(())
359 }
360
361 pub fn update_tracking_counts(
363 &mut self,
364 opened_count: i32,
365 will_attend_count: i32,
366 will_not_attend_count: i32,
367 ) {
368 self.opened_count = opened_count;
369 self.will_attend_count = will_attend_count;
370 self.will_not_attend_count = will_not_attend_count;
371 self.updated_at = Utc::now();
372 }
373
374 pub fn respects_legal_deadline(&self) -> bool {
376 match &self.actual_send_date {
377 Some(sent_at) => *sent_at <= self.minimum_send_date,
378 None => {
379 Utc::now() <= self.minimum_send_date
381 }
382 }
383 }
384
385 pub fn days_until_meeting(&self) -> i64 {
387 let now = Utc::now();
388 let duration = self.meeting_date.signed_duration_since(now);
389 duration.num_days()
390 }
391
392 pub fn should_send_reminder(&self) -> bool {
394 if self.status != ConvocationStatus::Sent {
395 return false;
396 }
397
398 if self.reminder_sent_at.is_some() {
399 return false; }
401
402 let days_until = self.days_until_meeting();
403 days_until <= 3 && days_until >= 0
404 }
405
406 pub fn opening_rate(&self) -> f64 {
408 if self.total_recipients == 0 {
409 return 0.0;
410 }
411 (self.opened_count as f64 / self.total_recipients as f64) * 100.0
412 }
413
414 pub fn attendance_rate(&self) -> f64 {
416 if self.total_recipients == 0 {
417 return 0.0;
418 }
419 (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
420 }
421}
422
423impl crate::domain::services::PieceDeGestion for Convocation {
424 fn acp_id(&self) -> Uuid {
425 self.acp_id
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 #[test]
434 fn test_meeting_type_minimum_notice_days() {
435 assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
437 assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 15);
438 assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 15);
439 }
440
441 #[test]
442 fn test_create_convocation_success() {
443 let org_id = Uuid::new_v4();
444 let building_id = Uuid::new_v4();
445 let meeting_id = Uuid::new_v4();
446 let creator_id = Uuid::new_v4();
447 let meeting_date = Utc::now() + Duration::days(20);
448
449 let convocation = Convocation::new(
450 Uuid::new_v4(), org_id,
452 building_id,
453 meeting_id,
454 ConvocationType::Ordinary,
455 meeting_date,
456 "FR".to_string(),
457 creator_id,
458 );
459
460 assert!(convocation.is_ok());
461 let conv = convocation.unwrap();
462 assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
463 assert_eq!(conv.language, "FR");
464 assert_eq!(conv.status, ConvocationStatus::Draft);
465 assert_eq!(conv.total_recipients, 0);
466 }
467
468 #[test]
469 fn test_create_convocation_meeting_too_soon() {
470 let meeting_date = Utc::now() + Duration::days(5); let result = Convocation::new(
473 Uuid::new_v4(), Uuid::new_v4(),
475 Uuid::new_v4(),
476 Uuid::new_v4(),
477 ConvocationType::Ordinary, meeting_date,
479 "FR".to_string(),
480 Uuid::new_v4(),
481 );
482
483 assert!(result.is_err());
484 let erreur = result.unwrap_err();
485 assert!(
486 erreur.contains("3.87"),
487 "le refus doit citer l'article qui le fonde, reçu : {erreur}"
488 );
489 assert!(
490 erreur.contains("Reportez"),
491 "le refus doit nommer le recours : à ce stade la date limite est \
492 dépassée, et reporter est la seule issue (#780). Reçu : {erreur}"
493 );
494 }
495
496 #[test]
497 fn test_create_convocation_invalid_language() {
498 let meeting_date = Utc::now() + Duration::days(20);
499
500 let result = Convocation::new(
501 Uuid::new_v4(), Uuid::new_v4(),
503 Uuid::new_v4(),
504 Uuid::new_v4(),
505 ConvocationType::Ordinary,
506 meeting_date,
507 "ES".to_string(), Uuid::new_v4(),
509 );
510
511 assert!(result.is_err());
512 assert!(result.unwrap_err().contains("Invalid language"));
513 }
514
515 #[test]
516 fn test_schedule_convocation() {
517 let meeting_date = Utc::now() + Duration::days(20);
518 let mut convocation = Convocation::new(
519 Uuid::new_v4(), Uuid::new_v4(),
521 Uuid::new_v4(),
522 Uuid::new_v4(),
523 ConvocationType::Ordinary,
524 meeting_date,
525 "FR".to_string(),
526 Uuid::new_v4(),
527 )
528 .unwrap();
529
530 let send_date = Utc::now() + Duration::days(3); let result = convocation.schedule(send_date);
532
533 assert!(result.is_ok());
534 assert_eq!(convocation.status, ConvocationStatus::Scheduled);
535 assert_eq!(convocation.scheduled_send_date, Some(send_date));
536 }
537
538 #[test]
539 fn test_schedule_convocation_too_late() {
540 let meeting_date = Utc::now() + Duration::days(20);
541 let mut convocation = Convocation::new(
542 Uuid::new_v4(), Uuid::new_v4(),
544 Uuid::new_v4(),
545 Uuid::new_v4(),
546 ConvocationType::Ordinary,
547 meeting_date,
548 "FR".to_string(),
549 Uuid::new_v4(),
550 )
551 .unwrap();
552
553 let send_date = meeting_date - Duration::days(10); let result = convocation.schedule(send_date);
556
557 assert!(result.is_err());
558 assert!(result.unwrap_err().contains("after minimum send date"));
559 }
560
561 #[test]
562 fn test_mark_sent() {
563 let meeting_date = Utc::now() + Duration::days(20);
564 let mut convocation = Convocation::new(
565 Uuid::new_v4(), Uuid::new_v4(),
567 Uuid::new_v4(),
568 Uuid::new_v4(),
569 ConvocationType::Ordinary,
570 meeting_date,
571 "FR".to_string(),
572 Uuid::new_v4(),
573 )
574 .unwrap();
575
576 let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
577
578 assert!(result.is_ok());
579 assert_eq!(convocation.status, ConvocationStatus::Sent);
580 assert!(convocation.actual_send_date.is_some());
581 assert_eq!(convocation.total_recipients, 50);
582 assert_eq!(
583 convocation.pdf_file_path,
584 Some("/uploads/convocations/conv-123.pdf".to_string())
585 );
586 }
587
588 #[test]
589 fn test_should_send_reminder() {
590 let far_meeting_date = Utc::now() + Duration::days(20);
593 let mut convocation_far = Convocation::new(
594 Uuid::new_v4(), Uuid::new_v4(),
596 Uuid::new_v4(),
597 Uuid::new_v4(),
598 ConvocationType::Extraordinary, far_meeting_date,
600 "FR".to_string(),
601 Uuid::new_v4(),
602 )
603 .unwrap();
604
605 convocation_far
606 .mark_sent("/uploads/conv.pdf".to_string(), 30)
607 .unwrap();
608
609 assert!(!convocation_far.should_send_reminder());
611
612 }
617
618 #[test]
619 fn test_opening_rate() {
620 let meeting_date = Utc::now() + Duration::days(20);
621 let mut convocation = Convocation::new(
622 Uuid::new_v4(), Uuid::new_v4(),
624 Uuid::new_v4(),
625 Uuid::new_v4(),
626 ConvocationType::Ordinary,
627 meeting_date,
628 "FR".to_string(),
629 Uuid::new_v4(),
630 )
631 .unwrap();
632
633 convocation
634 .mark_sent("/uploads/conv.pdf".to_string(), 100)
635 .unwrap();
636 convocation.update_tracking_counts(75, 50, 10);
637
638 assert_eq!(convocation.opening_rate(), 75.0);
639 assert_eq!(convocation.attendance_rate(), 50.0);
640 }
641
642 #[test]
643 fn test_respects_legal_deadline() {
644 let meeting_date = Utc::now() + Duration::days(20);
645 let mut convocation = Convocation::new(
646 Uuid::new_v4(), Uuid::new_v4(),
648 Uuid::new_v4(),
649 Uuid::new_v4(),
650 ConvocationType::Ordinary,
651 meeting_date,
652 "FR".to_string(),
653 Uuid::new_v4(),
654 )
655 .unwrap();
656
657 assert!(convocation.respects_legal_deadline());
659
660 convocation
662 .mark_sent("/uploads/conv.pdf".to_string(), 30)
663 .unwrap();
664 assert!(convocation.respects_legal_deadline());
665 }
666
667 #[test]
668 fn test_second_convocation_success() {
669 let first_meeting_date = Utc::now() + Duration::days(30);
671 let second_meeting_date = Utc::now() + Duration::days(50);
672 let first_meeting_id = Uuid::new_v4();
673 let new_meeting_id = Uuid::new_v4();
674
675 let result = Convocation::new_second_convocation(
676 Uuid::new_v4(), Uuid::new_v4(),
678 Uuid::new_v4(),
679 new_meeting_id,
680 first_meeting_id,
681 first_meeting_date,
682 second_meeting_date,
683 "FR".to_string(),
684 Uuid::new_v4(),
685 );
686
687 assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err());
688 let conv = result.unwrap();
689 assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
690 assert_eq!(conv.first_meeting_id, Some(first_meeting_id));
691 assert_eq!(conv.meeting_id, new_meeting_id);
692 }
693
694 #[test]
695 fn test_second_convocation_too_soon_fails() {
696 let first_meeting_date = Utc::now() + Duration::days(30);
698 let second_meeting_date = Utc::now() + Duration::days(40); let result = Convocation::new_second_convocation(
701 Uuid::new_v4(), Uuid::new_v4(),
703 Uuid::new_v4(),
704 Uuid::new_v4(),
705 Uuid::new_v4(),
706 first_meeting_date,
707 second_meeting_date,
708 "FR".to_string(),
709 Uuid::new_v4(),
710 );
711
712 assert!(result.is_err());
713 let erreur = result.unwrap_err();
719 assert!(
720 erreur.contains("3.87"),
721 "le refus doit citer l'article qui le fonde, reçu : {erreur}"
722 );
723 assert!(
724 erreur.contains("Reportez"),
725 "le refus doit nommer le recours, reçu : {erreur}"
726 );
727 }
728
729 #[test]
730 fn test_second_convocation_exactly_15_days_ok() {
731 let first_meeting_date = Utc::now() + Duration::days(30);
733 let second_meeting_date = Utc::now() + Duration::days(45);
734
735 let result = Convocation::new_second_convocation(
736 Uuid::new_v4(), Uuid::new_v4(),
738 Uuid::new_v4(),
739 Uuid::new_v4(),
740 Uuid::new_v4(),
741 first_meeting_date,
742 second_meeting_date,
743 "FR".to_string(),
744 Uuid::new_v4(),
745 );
746
747 assert!(result.is_ok());
748 let conv = result.unwrap();
749 assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
750 }
751}