1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
7pub enum MeetingType {
8 Ordinary, Extraordinary, }
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub enum MeetingStatus {
15 Scheduled,
16 Completed,
17 Cancelled,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
22pub struct Meeting {
23 pub id: Uuid,
24 pub organization_id: Uuid,
25 pub building_id: Uuid,
26 pub meeting_type: MeetingType,
27 pub title: String,
28 pub description: Option<String>,
29 pub scheduled_date: DateTime<Utc>,
30 pub location: String,
31 pub status: MeetingStatus,
32 pub agenda: Vec<String>,
33 pub attendees_count: Option<i32>,
34 pub quorum_validated: bool,
36 pub quorum_percentage: Option<f64>, pub total_quotas: Option<f64>, pub present_quotas: Option<f64>, pub is_second_convocation: bool, pub minutes_document_id: Option<Uuid>, pub minutes_sent_at: Option<DateTime<Utc>>, pub created_at: DateTime<Utc>,
45 pub updated_at: DateTime<Utc>,
46}
47
48impl Meeting {
49 pub fn new(
50 organization_id: Uuid,
51 building_id: Uuid,
52 meeting_type: MeetingType,
53 title: String,
54 description: Option<String>,
55 scheduled_date: DateTime<Utc>,
56 location: String,
57 ) -> Result<Self, String> {
58 if title.is_empty() {
59 return Err("Title cannot be empty".to_string());
60 }
61 if location.is_empty() {
62 return Err("Location cannot be empty".to_string());
63 }
64
65 let now = Utc::now();
66 Ok(Self {
67 id: Uuid::new_v4(),
68 organization_id,
69 building_id,
70 meeting_type,
71 title,
72 description,
73 scheduled_date,
74 location,
75 status: MeetingStatus::Scheduled,
76 agenda: Vec::new(),
77 attendees_count: None,
78 quorum_validated: false,
79 quorum_percentage: None,
80 total_quotas: None,
81 present_quotas: None,
82 is_second_convocation: false, minutes_document_id: None,
84 minutes_sent_at: None,
85 created_at: now,
86 updated_at: now,
87 })
88 }
89
90 pub fn add_agenda_item(&mut self, item: String) -> Result<(), String> {
91 if item.is_empty() {
92 return Err("Agenda item cannot be empty".to_string());
93 }
94 self.agenda.push(item);
95 self.updated_at = Utc::now();
96 Ok(())
97 }
98
99 pub fn complete(&mut self, attendees_count: i32) -> Result<(), String> {
100 match self.status {
101 MeetingStatus::Scheduled => {
102 self.status = MeetingStatus::Completed;
103 self.attendees_count = Some(attendees_count);
104 self.updated_at = Utc::now();
105 Ok(())
106 }
107 MeetingStatus::Completed => Err("Meeting is already completed".to_string()),
108 MeetingStatus::Cancelled => Err("Cannot complete a cancelled meeting".to_string()),
109 }
110 }
111
112 pub fn cancel(&mut self) -> Result<(), String> {
113 match self.status {
114 MeetingStatus::Scheduled => {
115 self.status = MeetingStatus::Cancelled;
116 self.updated_at = Utc::now();
117 Ok(())
118 }
119 MeetingStatus::Completed => Err("Cannot cancel a completed meeting".to_string()),
120 MeetingStatus::Cancelled => Err("Meeting is already cancelled".to_string()),
121 }
122 }
123
124 pub fn reschedule(&mut self, new_date: DateTime<Utc>) -> Result<(), String> {
125 match self.status {
126 MeetingStatus::Scheduled | MeetingStatus::Cancelled => {
127 self.scheduled_date = new_date;
128 self.status = MeetingStatus::Scheduled;
129 self.updated_at = Utc::now();
130 Ok(())
131 }
132 MeetingStatus::Completed => Err("Cannot reschedule a completed meeting".to_string()),
133 }
134 }
135
136 pub fn is_upcoming(&self) -> bool {
137 self.status == MeetingStatus::Scheduled && self.scheduled_date > Utc::now()
138 }
139
140 pub fn validate_quorum(
144 &mut self,
145 present_quotas: f64,
146 total_quotas: f64,
147 ) -> Result<bool, String> {
148 if total_quotas <= 0.0 {
149 return Err("Total quotas must be positive".to_string());
150 }
151 if present_quotas < 0.0 {
152 return Err("Present quotas cannot be negative".to_string());
153 }
154 if present_quotas > total_quotas {
155 return Err("Present quotas cannot exceed total quotas".to_string());
156 }
157
158 let percentage = (present_quotas / total_quotas) * 100.0;
159 let quorum_reached = percentage > 50.0;
161
162 self.present_quotas = Some(present_quotas);
163 self.total_quotas = Some(total_quotas);
164 self.quorum_percentage = Some(percentage);
165 self.quorum_validated = quorum_reached;
166 self.updated_at = Utc::now();
167
168 Ok(quorum_reached)
169 }
170
171 pub fn check_quorum_for_voting(&self) -> Result<(), String> {
177 if self.is_second_convocation {
179 return Ok(());
180 }
181
182 if self.quorum_percentage.is_none() {
183 return Err("Quorum has not been validated yet (Art. 3.87 §5 CC)".to_string());
184 }
185 if !self.quorum_validated {
186 let pct = self.quorum_percentage.unwrap_or(0.0);
187 return Err(format!(
188 "Quorum not reached: {:.1}% present (>50% required, Art. 3.87 §5 CC). \
189 A second convocation is required.",
190 pct
191 ));
192 }
193 Ok(())
194 }
195
196 pub fn set_minutes_sent(&mut self, document_id: Uuid) -> Result<(), String> {
199 if self.status != MeetingStatus::Completed {
200 return Err("Minutes can only be sent after meeting is completed".to_string());
201 }
202 self.minutes_document_id = Some(document_id);
203 self.minutes_sent_at = Some(Utc::now());
204 self.updated_at = Utc::now();
205 Ok(())
206 }
207
208 pub fn is_minutes_overdue(&self) -> bool {
211 if self.status != MeetingStatus::Completed {
212 return false;
213 }
214 if self.minutes_sent_at.is_some() {
215 return false;
216 }
217 let deadline = self.updated_at + Duration::days(30);
219 Utc::now() > deadline
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use chrono::Duration;
227
228 #[test]
229 fn test_create_meeting_success() {
230 let org_id = Uuid::new_v4();
231 let building_id = Uuid::new_v4();
232 let future_date = Utc::now() + Duration::days(30);
233
234 let meeting = Meeting::new(
235 org_id,
236 building_id,
237 MeetingType::Ordinary,
238 "AGO 2024".to_string(),
239 Some("Assemblée générale ordinaire annuelle".to_string()),
240 future_date,
241 "Salle des fêtes".to_string(),
242 );
243
244 assert!(meeting.is_ok());
245 let meeting = meeting.unwrap();
246 assert_eq!(meeting.organization_id, org_id);
247 assert_eq!(meeting.status, MeetingStatus::Scheduled);
248 assert!(meeting.is_upcoming());
249 }
250
251 #[test]
252 fn test_add_agenda_item() {
253 let org_id = Uuid::new_v4();
254 let building_id = Uuid::new_v4();
255 let future_date = Utc::now() + Duration::days(30);
256
257 let mut meeting = Meeting::new(
258 org_id,
259 building_id,
260 MeetingType::Ordinary,
261 "AGO 2024".to_string(),
262 None,
263 future_date,
264 "Salle des fêtes".to_string(),
265 )
266 .unwrap();
267
268 let result = meeting.add_agenda_item("Approbation des comptes".to_string());
269 assert!(result.is_ok());
270 assert_eq!(meeting.agenda.len(), 1);
271 }
272
273 #[test]
274 fn test_complete_meeting() {
275 let org_id = Uuid::new_v4();
276 let building_id = Uuid::new_v4();
277 let future_date = Utc::now() + Duration::days(30);
278
279 let mut meeting = Meeting::new(
280 org_id,
281 building_id,
282 MeetingType::Ordinary,
283 "AGO 2024".to_string(),
284 None,
285 future_date,
286 "Salle des fêtes".to_string(),
287 )
288 .unwrap();
289
290 let result = meeting.complete(45);
291 assert!(result.is_ok());
292 assert_eq!(meeting.status, MeetingStatus::Completed);
293 assert_eq!(meeting.attendees_count, Some(45));
294 assert!(!meeting.is_upcoming());
295 }
296
297 #[test]
298 fn test_complete_already_completed_fails() {
299 let org_id = Uuid::new_v4();
300 let building_id = Uuid::new_v4();
301 let future_date = Utc::now() + Duration::days(30);
302
303 let mut meeting = Meeting::new(
304 org_id,
305 building_id,
306 MeetingType::Ordinary,
307 "AGO 2024".to_string(),
308 None,
309 future_date,
310 "Salle des fêtes".to_string(),
311 )
312 .unwrap();
313
314 meeting.complete(45).unwrap();
315 let result = meeting.complete(50);
316 assert!(result.is_err());
317 assert_eq!(meeting.attendees_count, Some(45)); }
319
320 #[test]
321 fn test_cancel_meeting() {
322 let org_id = Uuid::new_v4();
323 let building_id = Uuid::new_v4();
324 let future_date = Utc::now() + Duration::days(30);
325
326 let mut meeting = Meeting::new(
327 org_id,
328 building_id,
329 MeetingType::Ordinary,
330 "AGO 2024".to_string(),
331 None,
332 future_date,
333 "Salle des fêtes".to_string(),
334 )
335 .unwrap();
336
337 let result = meeting.cancel();
338 assert!(result.is_ok());
339 assert_eq!(meeting.status, MeetingStatus::Cancelled);
340 }
341
342 #[test]
343 fn test_quorum_reached_above_50_percent() {
344 let org_id = Uuid::new_v4();
345 let building_id = Uuid::new_v4();
346 let future_date = Utc::now() + Duration::days(30);
347
348 let mut meeting = Meeting::new(
349 org_id,
350 building_id,
351 MeetingType::Ordinary,
352 "AGO 2024".to_string(),
353 None,
354 future_date,
355 "Salle des fêtes".to_string(),
356 )
357 .unwrap();
358
359 let result = meeting.validate_quorum(600.0, 1000.0);
361 assert!(result.is_ok());
362 assert!(result.unwrap());
363 assert!(meeting.quorum_validated);
364 assert!((meeting.quorum_percentage.unwrap() - 60.0).abs() < 0.01);
365 }
366
367 #[test]
368 fn test_quorum_not_reached_at_50_percent_exact() {
369 let org_id = Uuid::new_v4();
370 let building_id = Uuid::new_v4();
371 let future_date = Utc::now() + Duration::days(30);
372
373 let mut meeting = Meeting::new(
374 org_id,
375 building_id,
376 MeetingType::Ordinary,
377 "AGO 2024".to_string(),
378 None,
379 future_date,
380 "Salle des fêtes".to_string(),
381 )
382 .unwrap();
383
384 let result = meeting.validate_quorum(500.0, 1000.0);
386 assert!(result.is_ok());
387 assert!(!result.unwrap());
388 assert!(!meeting.quorum_validated);
389 }
390
391 #[test]
392 fn test_quorum_not_reached_below_50_percent() {
393 let org_id = Uuid::new_v4();
394 let building_id = Uuid::new_v4();
395 let future_date = Utc::now() + Duration::days(30);
396
397 let mut meeting = Meeting::new(
398 org_id,
399 building_id,
400 MeetingType::Ordinary,
401 "AGO 2024".to_string(),
402 None,
403 future_date,
404 "Salle des fêtes".to_string(),
405 )
406 .unwrap();
407
408 let result = meeting.validate_quorum(400.0, 1000.0);
410 assert!(result.is_ok());
411 assert!(!result.unwrap());
412 assert!(!meeting.quorum_validated);
413 }
414
415 #[test]
416 fn test_check_quorum_blocks_vote_when_not_validated() {
417 let org_id = Uuid::new_v4();
418 let building_id = Uuid::new_v4();
419 let future_date = Utc::now() + Duration::days(30);
420
421 let meeting = Meeting::new(
422 org_id,
423 building_id,
424 MeetingType::Ordinary,
425 "AGO 2024".to_string(),
426 None,
427 future_date,
428 "Salle des fêtes".to_string(),
429 )
430 .unwrap();
431
432 let result = meeting.check_quorum_for_voting();
433 assert!(result.is_err());
434 assert!(result.unwrap_err().contains("not been validated yet"));
435 }
436
437 #[test]
438 fn test_check_quorum_skipped_for_second_convocation() {
439 let org_id = Uuid::new_v4();
441 let building_id = Uuid::new_v4();
442 let future_date = Utc::now() + Duration::days(30);
443
444 let mut meeting = Meeting::new(
445 org_id,
446 building_id,
447 MeetingType::Extraordinary,
448 "2e Convocation AGE".to_string(),
449 Some("Deuxième convocation - sans quorum".to_string()),
450 future_date,
451 "Salle des fêtes".to_string(),
452 )
453 .unwrap();
454
455 meeting.is_second_convocation = true;
457
458 let result = meeting.check_quorum_for_voting();
460 assert!(result.is_ok(), "2nd convocation should skip quorum check");
461 }
462
463 #[test]
464 fn test_check_quorum_blocks_vote_when_quorum_not_reached() {
465 let org_id = Uuid::new_v4();
466 let building_id = Uuid::new_v4();
467 let future_date = Utc::now() + Duration::days(30);
468
469 let mut meeting = Meeting::new(
470 org_id,
471 building_id,
472 MeetingType::Ordinary,
473 "AGO 2024".to_string(),
474 None,
475 future_date,
476 "Salle des fêtes".to_string(),
477 )
478 .unwrap();
479
480 meeting.validate_quorum(400.0, 1000.0).unwrap();
481 let result = meeting.check_quorum_for_voting();
482 assert!(result.is_err());
483 assert!(result.unwrap_err().contains("second convocation"));
484 }
485
486 #[test]
487 fn test_quorum_invalid_total_quotas() {
488 let org_id = Uuid::new_v4();
489 let building_id = Uuid::new_v4();
490 let future_date = Utc::now() + Duration::days(30);
491
492 let mut meeting = Meeting::new(
493 org_id,
494 building_id,
495 MeetingType::Ordinary,
496 "AGO 2024".to_string(),
497 None,
498 future_date,
499 "Salle des fêtes".to_string(),
500 )
501 .unwrap();
502
503 let result = meeting.validate_quorum(100.0, 0.0);
504 assert!(result.is_err());
505 }
506
507 #[test]
508 fn test_reschedule_meeting() {
509 let org_id = Uuid::new_v4();
510 let building_id = Uuid::new_v4();
511 let future_date = Utc::now() + Duration::days(30);
512
513 let mut meeting = Meeting::new(
514 org_id,
515 building_id,
516 MeetingType::Ordinary,
517 "AGO 2024".to_string(),
518 None,
519 future_date,
520 "Salle des fêtes".to_string(),
521 )
522 .unwrap();
523
524 let new_date = Utc::now() + Duration::days(60);
525 let result = meeting.reschedule(new_date);
526 assert!(result.is_ok());
527 assert_eq!(meeting.scheduled_date, new_date);
528 }
529
530 #[test]
531 fn test_set_minutes_sent_success() {
532 let org_id = Uuid::new_v4();
534 let building_id = Uuid::new_v4();
535 let future_date = Utc::now() + Duration::days(30);
536 let doc_id = Uuid::new_v4();
537
538 let mut meeting = Meeting::new(
539 org_id,
540 building_id,
541 MeetingType::Ordinary,
542 "AGO 2024".to_string(),
543 None,
544 future_date,
545 "Salle des fêtes".to_string(),
546 )
547 .unwrap();
548
549 meeting.complete(45).unwrap();
551 let result = meeting.set_minutes_sent(doc_id);
552
553 assert!(result.is_ok());
555 assert_eq!(meeting.minutes_document_id, Some(doc_id));
556 assert!(meeting.minutes_sent_at.is_some());
557 }
558
559 #[test]
560 fn test_set_minutes_sent_before_completion_fails() {
561 let org_id = Uuid::new_v4();
563 let building_id = Uuid::new_v4();
564 let future_date = Utc::now() + Duration::days(30);
565 let doc_id = Uuid::new_v4();
566
567 let mut meeting = Meeting::new(
568 org_id,
569 building_id,
570 MeetingType::Ordinary,
571 "AGO 2024".to_string(),
572 None,
573 future_date,
574 "Salle des fêtes".to_string(),
575 )
576 .unwrap();
577
578 let result = meeting.set_minutes_sent(doc_id);
580
581 assert!(result.is_err());
583 assert_eq!(
584 result.unwrap_err(),
585 "Minutes can only be sent after meeting is completed"
586 );
587 }
588
589 #[test]
590 fn test_is_minutes_overdue_not_completed() {
591 let org_id = Uuid::new_v4();
593 let building_id = Uuid::new_v4();
594 let future_date = Utc::now() + Duration::days(30);
595
596 let meeting = Meeting::new(
597 org_id,
598 building_id,
599 MeetingType::Ordinary,
600 "AGO 2024".to_string(),
601 None,
602 future_date,
603 "Salle des fêtes".to_string(),
604 )
605 .unwrap();
606
607 assert!(!meeting.is_minutes_overdue()); }
610
611 #[test]
612 fn test_is_minutes_overdue_sent() {
613 let org_id = Uuid::new_v4();
615 let building_id = Uuid::new_v4();
616 let future_date = Utc::now() + Duration::days(30);
617 let doc_id = Uuid::new_v4();
618
619 let mut meeting = Meeting::new(
620 org_id,
621 building_id,
622 MeetingType::Ordinary,
623 "AGO 2024".to_string(),
624 None,
625 future_date,
626 "Salle des fêtes".to_string(),
627 )
628 .unwrap();
629
630 meeting.complete(45).unwrap();
632 meeting.set_minutes_sent(doc_id).unwrap();
633
634 assert!(!meeting.is_minutes_overdue()); }
637
638 #[test]
639 fn test_is_minutes_overdue_past_30_days() {
640 let org_id = Uuid::new_v4();
642 let building_id = Uuid::new_v4();
643 let future_date = Utc::now() + Duration::days(30);
644
645 let mut meeting = Meeting::new(
646 org_id,
647 building_id,
648 MeetingType::Ordinary,
649 "AGO 2024".to_string(),
650 None,
651 future_date,
652 "Salle des fêtes".to_string(),
653 )
654 .unwrap();
655
656 meeting.complete(45).unwrap();
658 meeting.updated_at = Utc::now() - Duration::days(31);
659
660 assert!(meeting.is_minutes_overdue()); }
663
664 #[test]
665 fn test_is_minutes_overdue_within_30_days() {
666 let org_id = Uuid::new_v4();
668 let building_id = Uuid::new_v4();
669 let future_date = Utc::now() + Duration::days(30);
670
671 let mut meeting = Meeting::new(
672 org_id,
673 building_id,
674 MeetingType::Ordinary,
675 "AGO 2024".to_string(),
676 None,
677 future_date,
678 "Salle des fêtes".to_string(),
679 )
680 .unwrap();
681
682 meeting.complete(45).unwrap();
684
685 assert!(!meeting.is_minutes_overdue()); }
688}