1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
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)]
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 pub organization_id: Uuid,
99 pub building_id: Uuid,
100 pub meeting_id: Uuid,
101 pub meeting_type: ConvocationType,
102 pub meeting_date: DateTime<Utc>,
103 pub status: ConvocationStatus,
104
105 pub first_meeting_id: Option<Uuid>,
107
108 pub no_quorum_required: bool,
111
112 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,
123 pub opened_count: i32,
124 pub will_attend_count: i32,
125 pub will_not_attend_count: i32,
126
127 pub reminder_sent_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>,
132 pub updated_at: DateTime<Utc>,
133 pub created_by: Uuid,
134}
135
136impl Convocation {
137 pub fn new(
151 organization_id: Uuid,
152 building_id: Uuid,
153 meeting_id: Uuid,
154 meeting_type: ConvocationType,
155 meeting_date: DateTime<Utc>,
156 language: String,
157 created_by: Uuid,
158 ) -> Result<Self, String> {
159 if !["FR", "NL", "DE", "EN"].contains(&language.to_uppercase().as_str()) {
161 return Err(format!(
162 "Invalid language '{}'. Must be FR, NL, DE, or EN",
163 language
164 ));
165 }
166
167 let minimum_notice_days = meeting_type.minimum_notice_days();
169 let minimum_send_date = meeting_date - Duration::days(minimum_notice_days);
170
171 let now = Utc::now();
173 if minimum_send_date < now {
174 return Err(format!(
175 "Meeting date too soon. {} meeting requires {} days notice. Minimum send date would be {}",
176 match meeting_type {
177 ConvocationType::Ordinary => "Ordinary",
178 ConvocationType::Extraordinary => "Extraordinary",
179 ConvocationType::SecondConvocation => "Second convocation",
180 },
181 minimum_notice_days,
182 minimum_send_date.format("%Y-%m-%d %H:%M")
183 ));
184 }
185
186 Ok(Self {
187 id: Uuid::new_v4(),
188 organization_id,
189 building_id,
190 meeting_id,
191 meeting_type,
192 meeting_date,
193 status: ConvocationStatus::Draft,
194 first_meeting_id: None,
195 no_quorum_required: false, minimum_send_date,
197 actual_send_date: None,
198 scheduled_send_date: None,
199 pdf_file_path: None,
200 language: language.to_uppercase(),
201 total_recipients: 0,
202 opened_count: 0,
203 will_attend_count: 0,
204 will_not_attend_count: 0,
205 reminder_sent_at: None,
206 created_at: now,
207 updated_at: now,
208 created_by,
209 })
210 }
211
212 pub fn new_second_convocation(
220 organization_id: Uuid,
221 building_id: Uuid,
222 new_meeting_id: Uuid,
223 first_meeting_id: Uuid,
224 first_meeting_date: DateTime<Utc>,
225 new_meeting_date: DateTime<Utc>,
226 language: String,
227 created_by: Uuid,
228 ) -> Result<Self, String> {
229 let min_second_date = first_meeting_date + Duration::days(15);
231 if new_meeting_date < min_second_date {
232 return Err(format!(
233 "Second convocation meeting date {} must be at least 15 days after the first \
234 meeting date {} (Art. 3.87 §3 CC). Minimum date: {}",
235 new_meeting_date.format("%Y-%m-%d"),
236 first_meeting_date.format("%Y-%m-%d"),
237 min_second_date.format("%Y-%m-%d")
238 ));
239 }
240
241 let mut convocation = Self::new(
242 organization_id,
243 building_id,
244 new_meeting_id,
245 ConvocationType::SecondConvocation,
246 new_meeting_date,
247 language,
248 created_by,
249 )?;
250
251 convocation.first_meeting_id = Some(first_meeting_id);
252 convocation.no_quorum_required = true;
254 Ok(convocation)
255 }
256
257 pub fn schedule(&mut self, send_date: DateTime<Utc>) -> Result<(), String> {
259 if self.status != ConvocationStatus::Draft {
260 return Err(format!(
261 "Cannot schedule convocation in status '{:?}'. Must be Draft",
262 self.status
263 ));
264 }
265
266 if send_date > self.minimum_send_date {
268 return Err(format!(
269 "Scheduled send date {} is after minimum send date {}. Meeting would not have required notice period",
270 send_date.format("%Y-%m-%d %H:%M"),
271 self.minimum_send_date.format("%Y-%m-%d %H:%M")
272 ));
273 }
274
275 self.scheduled_send_date = Some(send_date);
276 self.status = ConvocationStatus::Scheduled;
277 self.updated_at = Utc::now();
278 Ok(())
279 }
280
281 pub fn mark_sent(
283 &mut self,
284 pdf_file_path: String,
285 total_recipients: i32,
286 ) -> Result<(), String> {
287 if self.status != ConvocationStatus::Draft && self.status != ConvocationStatus::Scheduled {
288 return Err(format!(
289 "Cannot send convocation in status '{:?}'",
290 self.status
291 ));
292 }
293
294 if total_recipients <= 0 {
295 return Err("Total recipients must be greater than 0".to_string());
296 }
297
298 self.status = ConvocationStatus::Sent;
299 self.actual_send_date = Some(Utc::now());
300 self.pdf_file_path = Some(pdf_file_path);
301 self.total_recipients = total_recipients;
302 self.updated_at = Utc::now();
303 Ok(())
304 }
305
306 pub fn cancel(&mut self) -> Result<(), String> {
308 if self.status == ConvocationStatus::Cancelled {
309 return Err("Convocation is already cancelled".to_string());
310 }
311
312 self.status = ConvocationStatus::Cancelled;
313 self.updated_at = Utc::now();
314 Ok(())
315 }
316
317 pub fn mark_reminder_sent(&mut self) -> Result<(), String> {
319 if self.status != ConvocationStatus::Sent {
320 return Err("Cannot send reminder for unsent convocation".to_string());
321 }
322
323 self.reminder_sent_at = Some(Utc::now());
324 self.updated_at = Utc::now();
325 Ok(())
326 }
327
328 pub fn update_tracking_counts(
330 &mut self,
331 opened_count: i32,
332 will_attend_count: i32,
333 will_not_attend_count: i32,
334 ) {
335 self.opened_count = opened_count;
336 self.will_attend_count = will_attend_count;
337 self.will_not_attend_count = will_not_attend_count;
338 self.updated_at = Utc::now();
339 }
340
341 pub fn respects_legal_deadline(&self) -> bool {
343 match &self.actual_send_date {
344 Some(sent_at) => *sent_at <= self.minimum_send_date,
345 None => {
346 Utc::now() <= self.minimum_send_date
348 }
349 }
350 }
351
352 pub fn days_until_meeting(&self) -> i64 {
354 let now = Utc::now();
355 let duration = self.meeting_date.signed_duration_since(now);
356 duration.num_days()
357 }
358
359 pub fn should_send_reminder(&self) -> bool {
361 if self.status != ConvocationStatus::Sent {
362 return false;
363 }
364
365 if self.reminder_sent_at.is_some() {
366 return false; }
368
369 let days_until = self.days_until_meeting();
370 days_until <= 3 && days_until >= 0
371 }
372
373 pub fn opening_rate(&self) -> f64 {
375 if self.total_recipients == 0 {
376 return 0.0;
377 }
378 (self.opened_count as f64 / self.total_recipients as f64) * 100.0
379 }
380
381 pub fn attendance_rate(&self) -> f64 {
383 if self.total_recipients == 0 {
384 return 0.0;
385 }
386 (self.will_attend_count as f64 / self.total_recipients as f64) * 100.0
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 #[test]
395 fn test_meeting_type_minimum_notice_days() {
396 assert_eq!(ConvocationType::Ordinary.minimum_notice_days(), 15);
398 assert_eq!(ConvocationType::Extraordinary.minimum_notice_days(), 15);
399 assert_eq!(ConvocationType::SecondConvocation.minimum_notice_days(), 15);
400 }
401
402 #[test]
403 fn test_create_convocation_success() {
404 let org_id = Uuid::new_v4();
405 let building_id = Uuid::new_v4();
406 let meeting_id = Uuid::new_v4();
407 let creator_id = Uuid::new_v4();
408 let meeting_date = Utc::now() + Duration::days(20);
409
410 let convocation = Convocation::new(
411 org_id,
412 building_id,
413 meeting_id,
414 ConvocationType::Ordinary,
415 meeting_date,
416 "FR".to_string(),
417 creator_id,
418 );
419
420 assert!(convocation.is_ok());
421 let conv = convocation.unwrap();
422 assert_eq!(conv.meeting_type, ConvocationType::Ordinary);
423 assert_eq!(conv.language, "FR");
424 assert_eq!(conv.status, ConvocationStatus::Draft);
425 assert_eq!(conv.total_recipients, 0);
426 }
427
428 #[test]
429 fn test_create_convocation_meeting_too_soon() {
430 let meeting_date = Utc::now() + Duration::days(5); let result = Convocation::new(
433 Uuid::new_v4(),
434 Uuid::new_v4(),
435 Uuid::new_v4(),
436 ConvocationType::Ordinary, meeting_date,
438 "FR".to_string(),
439 Uuid::new_v4(),
440 );
441
442 assert!(result.is_err());
443 assert!(result.unwrap_err().contains("Meeting date too soon"));
444 }
445
446 #[test]
447 fn test_create_convocation_invalid_language() {
448 let meeting_date = Utc::now() + Duration::days(20);
449
450 let result = Convocation::new(
451 Uuid::new_v4(),
452 Uuid::new_v4(),
453 Uuid::new_v4(),
454 ConvocationType::Ordinary,
455 meeting_date,
456 "ES".to_string(), Uuid::new_v4(),
458 );
459
460 assert!(result.is_err());
461 assert!(result.unwrap_err().contains("Invalid language"));
462 }
463
464 #[test]
465 fn test_schedule_convocation() {
466 let meeting_date = Utc::now() + Duration::days(20);
467 let mut convocation = Convocation::new(
468 Uuid::new_v4(),
469 Uuid::new_v4(),
470 Uuid::new_v4(),
471 ConvocationType::Ordinary,
472 meeting_date,
473 "FR".to_string(),
474 Uuid::new_v4(),
475 )
476 .unwrap();
477
478 let send_date = Utc::now() + Duration::days(3); let result = convocation.schedule(send_date);
480
481 assert!(result.is_ok());
482 assert_eq!(convocation.status, ConvocationStatus::Scheduled);
483 assert_eq!(convocation.scheduled_send_date, Some(send_date));
484 }
485
486 #[test]
487 fn test_schedule_convocation_too_late() {
488 let meeting_date = Utc::now() + Duration::days(20);
489 let mut convocation = Convocation::new(
490 Uuid::new_v4(),
491 Uuid::new_v4(),
492 Uuid::new_v4(),
493 ConvocationType::Ordinary,
494 meeting_date,
495 "FR".to_string(),
496 Uuid::new_v4(),
497 )
498 .unwrap();
499
500 let send_date = meeting_date - Duration::days(10); let result = convocation.schedule(send_date);
503
504 assert!(result.is_err());
505 assert!(result.unwrap_err().contains("after minimum send date"));
506 }
507
508 #[test]
509 fn test_mark_sent() {
510 let meeting_date = Utc::now() + Duration::days(20);
511 let mut convocation = Convocation::new(
512 Uuid::new_v4(),
513 Uuid::new_v4(),
514 Uuid::new_v4(),
515 ConvocationType::Ordinary,
516 meeting_date,
517 "FR".to_string(),
518 Uuid::new_v4(),
519 )
520 .unwrap();
521
522 let result = convocation.mark_sent("/uploads/convocations/conv-123.pdf".to_string(), 50);
523
524 assert!(result.is_ok());
525 assert_eq!(convocation.status, ConvocationStatus::Sent);
526 assert!(convocation.actual_send_date.is_some());
527 assert_eq!(convocation.total_recipients, 50);
528 assert_eq!(
529 convocation.pdf_file_path,
530 Some("/uploads/convocations/conv-123.pdf".to_string())
531 );
532 }
533
534 #[test]
535 fn test_should_send_reminder() {
536 let far_meeting_date = Utc::now() + Duration::days(20);
539 let mut convocation_far = Convocation::new(
540 Uuid::new_v4(),
541 Uuid::new_v4(),
542 Uuid::new_v4(),
543 ConvocationType::Extraordinary, far_meeting_date,
545 "FR".to_string(),
546 Uuid::new_v4(),
547 )
548 .unwrap();
549
550 convocation_far
551 .mark_sent("/uploads/conv.pdf".to_string(), 30)
552 .unwrap();
553
554 assert!(!convocation_far.should_send_reminder());
556
557 }
562
563 #[test]
564 fn test_opening_rate() {
565 let meeting_date = Utc::now() + Duration::days(20);
566 let mut convocation = Convocation::new(
567 Uuid::new_v4(),
568 Uuid::new_v4(),
569 Uuid::new_v4(),
570 ConvocationType::Ordinary,
571 meeting_date,
572 "FR".to_string(),
573 Uuid::new_v4(),
574 )
575 .unwrap();
576
577 convocation
578 .mark_sent("/uploads/conv.pdf".to_string(), 100)
579 .unwrap();
580 convocation.update_tracking_counts(75, 50, 10);
581
582 assert_eq!(convocation.opening_rate(), 75.0);
583 assert_eq!(convocation.attendance_rate(), 50.0);
584 }
585
586 #[test]
587 fn test_respects_legal_deadline() {
588 let meeting_date = Utc::now() + Duration::days(20);
589 let mut convocation = Convocation::new(
590 Uuid::new_v4(),
591 Uuid::new_v4(),
592 Uuid::new_v4(),
593 ConvocationType::Ordinary,
594 meeting_date,
595 "FR".to_string(),
596 Uuid::new_v4(),
597 )
598 .unwrap();
599
600 assert!(convocation.respects_legal_deadline());
602
603 convocation
605 .mark_sent("/uploads/conv.pdf".to_string(), 30)
606 .unwrap();
607 assert!(convocation.respects_legal_deadline());
608 }
609
610 #[test]
611 fn test_second_convocation_success() {
612 let first_meeting_date = Utc::now() + Duration::days(30);
614 let second_meeting_date = Utc::now() + Duration::days(50);
615 let first_meeting_id = Uuid::new_v4();
616 let new_meeting_id = Uuid::new_v4();
617
618 let result = Convocation::new_second_convocation(
619 Uuid::new_v4(),
620 Uuid::new_v4(),
621 new_meeting_id,
622 first_meeting_id,
623 first_meeting_date,
624 second_meeting_date,
625 "FR".to_string(),
626 Uuid::new_v4(),
627 );
628
629 assert!(result.is_ok(), "Expected Ok but got: {:?}", result.err());
630 let conv = result.unwrap();
631 assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
632 assert_eq!(conv.first_meeting_id, Some(first_meeting_id));
633 assert_eq!(conv.meeting_id, new_meeting_id);
634 }
635
636 #[test]
637 fn test_second_convocation_too_soon_fails() {
638 let first_meeting_date = Utc::now() + Duration::days(30);
640 let second_meeting_date = Utc::now() + Duration::days(40); let result = Convocation::new_second_convocation(
643 Uuid::new_v4(),
644 Uuid::new_v4(),
645 Uuid::new_v4(),
646 Uuid::new_v4(),
647 first_meeting_date,
648 second_meeting_date,
649 "FR".to_string(),
650 Uuid::new_v4(),
651 );
652
653 assert!(result.is_err());
654 assert!(result.unwrap_err().contains("15 days after"));
655 }
656
657 #[test]
658 fn test_second_convocation_exactly_15_days_ok() {
659 let first_meeting_date = Utc::now() + Duration::days(30);
661 let second_meeting_date = Utc::now() + Duration::days(45);
662
663 let result = Convocation::new_second_convocation(
664 Uuid::new_v4(),
665 Uuid::new_v4(),
666 Uuid::new_v4(),
667 Uuid::new_v4(),
668 first_meeting_date,
669 second_meeting_date,
670 "FR".to_string(),
671 Uuid::new_v4(),
672 );
673
674 assert!(result.is_ok());
675 let conv = result.unwrap();
676 assert_eq!(conv.meeting_type, ConvocationType::SecondConvocation);
677 }
678}