1use chrono::{DateTime, Duration, Utc};
2use serde::{Deserialize, Serialize};
3use uuid::Uuid;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7pub enum BoardPosition {
8 President, Treasurer, Member, }
12
13impl std::fmt::Display for BoardPosition {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 match self {
16 BoardPosition::President => write!(f, "president"),
17 BoardPosition::Treasurer => write!(f, "treasurer"),
18 BoardPosition::Member => write!(f, "member"),
19 }
20 }
21}
22
23impl std::str::FromStr for BoardPosition {
24 type Err = String;
25
26 fn from_str(s: &str) -> Result<Self, Self::Err> {
27 match s.to_lowercase().as_str() {
28 "president" => Ok(BoardPosition::President),
29 "treasurer" => Ok(BoardPosition::Treasurer),
30 "member" => Ok(BoardPosition::Member),
31 _ => Err(format!("Invalid board position: {}", s)),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BoardMember {
41 pub id: Uuid,
42 pub owner_id: Uuid, pub building_id: Uuid,
44 pub position: BoardPosition,
45 pub mandate_start: DateTime<Utc>,
46 pub mandate_end: DateTime<Utc>,
47 pub elected_by_meeting_id: Uuid, pub created_at: DateTime<Utc>,
49 pub updated_at: DateTime<Utc>,
50}
51
52impl BoardMember {
53 pub fn new(
61 owner_id: Uuid,
62 building_id: Uuid,
63 position: BoardPosition,
64 mandate_start: DateTime<Utc>,
65 mandate_end: DateTime<Utc>,
66 elected_by_meeting_id: Uuid,
67 ) -> Result<Self, String> {
68 if mandate_start >= mandate_end {
70 return Err("Mandate start date must be before end date".to_string());
71 }
72
73 let duration_days = (mandate_end - mandate_start).num_days();
76 if !(330..=395).contains(&duration_days) {
77 return Err("Mandate duration must be approximately 1 year (Art. 3.90 CC)".to_string());
78 }
79
80 let now = Utc::now();
81 Ok(Self {
82 id: Uuid::new_v4(),
83 owner_id,
84 building_id,
85 position,
86 mandate_start,
87 mandate_end,
88 elected_by_meeting_id,
89 created_at: now,
90 updated_at: now,
91 })
92 }
93
94 pub fn is_active_at(&self, moment: DateTime<Utc>) -> bool {
101 moment >= self.mandate_start && moment < self.mandate_end
102 }
103
104 pub fn is_active(&self) -> bool {
106 self.is_active_at(Utc::now())
107 }
108
109 pub fn resign(&mut self, at: DateTime<Utc>) {
113 self.mandate_end = self.mandate_end.min(at);
114 self.updated_at = Utc::now();
115 }
116
117 pub fn days_remaining(&self) -> i64 {
120 let now = Utc::now();
121 if now > self.mandate_end {
122 return 0;
123 }
124 (self.mandate_end - now).num_days()
125 }
126
127 pub fn expires_soon(&self) -> bool {
129 self.days_remaining() > 0 && self.days_remaining() < 60
130 }
131
132 pub fn extend_mandate(
135 &mut self,
136 mandate_duration_days: i64,
137 new_elected_by_meeting_id: Uuid,
138 ) -> Result<(), String> {
139 if !self.expires_soon() && self.is_active() {
140 return Err("Cannot extend mandate more than 60 days before expiration".to_string());
141 }
142
143 if !(330..=395).contains(&mandate_duration_days) {
145 return Err("Mandate duration must be approximately 1 year (Art. 3.90 CC)".to_string());
146 }
147
148 self.mandate_start = self.mandate_end;
150 self.mandate_end = self.mandate_start + Duration::days(mandate_duration_days);
151 self.elected_by_meeting_id = new_elected_by_meeting_id;
152 self.updated_at = Utc::now();
153
154 Ok(())
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn test_create_board_member_success() {
164 let owner_id = Uuid::new_v4();
166 let building_id = Uuid::new_v4();
167 let meeting_id = Uuid::new_v4();
168 let start = Utc::now();
169 let end = start + Duration::days(365);
170
171 let result = BoardMember::new(
173 owner_id,
174 building_id,
175 BoardPosition::President,
176 start,
177 end,
178 meeting_id,
179 );
180
181 assert!(result.is_ok());
183 let member = result.unwrap();
184 assert_eq!(member.owner_id, owner_id);
185 assert_eq!(member.building_id, building_id);
186 assert_eq!(member.position, BoardPosition::President);
187 assert_eq!(member.mandate_start, start);
188 assert_eq!(member.mandate_end, end);
189 assert_eq!(member.elected_by_meeting_id, meeting_id);
190 }
191
192 #[test]
193 fn test_mandate_duration_one_year() {
194 let start = Utc::now();
196 let end = start + Duration::days(365);
197
198 let result = BoardMember::new(
200 Uuid::new_v4(),
201 Uuid::new_v4(),
202 BoardPosition::Member,
203 start,
204 end,
205 Uuid::new_v4(),
206 );
207
208 assert!(result.is_ok());
210 }
211
212 #[test]
213 fn test_mandate_duration_too_short_fails() {
214 let start = Utc::now();
216 let end = start + Duration::days(300);
217
218 let result = BoardMember::new(
220 Uuid::new_v4(),
221 Uuid::new_v4(),
222 BoardPosition::Member,
223 start,
224 end,
225 Uuid::new_v4(),
226 );
227
228 assert!(result.is_err());
230 assert_eq!(
231 result.unwrap_err(),
232 "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
233 );
234 }
235
236 #[test]
237 fn test_mandate_duration_too_long_fails() {
238 let start = Utc::now();
241 let end = start + Duration::days(730);
242
243 let result = BoardMember::new(
245 Uuid::new_v4(),
246 Uuid::new_v4(),
247 BoardPosition::Member,
248 start,
249 end,
250 Uuid::new_v4(),
251 );
252
253 assert!(result.is_err());
255 assert_eq!(
256 result.unwrap_err(),
257 "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
258 );
259 }
260
261 #[test]
262 fn test_mandate_duration_boundaries() {
263 let start = Utc::now();
265 let mk = |days: i64| {
266 BoardMember::new(
267 Uuid::new_v4(),
268 Uuid::new_v4(),
269 BoardPosition::Member,
270 start,
271 start + Duration::days(days),
272 Uuid::new_v4(),
273 )
274 };
275
276 assert!(mk(330).is_ok(), "330 days must be accepted (lower bound)");
278 assert!(mk(395).is_ok(), "395 days must be accepted (upper bound)");
279 assert!(mk(329).is_err(), "329 days must be rejected (< 330)");
281 assert!(mk(396).is_err(), "396 days must be rejected (> 395)");
282 }
283
284 #[test]
285 fn test_mandate_duration_far_too_long_fails() {
286 let start = Utc::now();
289 let end = start + Duration::days(1100);
290
291 let result = BoardMember::new(
293 Uuid::new_v4(),
294 Uuid::new_v4(),
295 BoardPosition::Member,
296 start,
297 end,
298 Uuid::new_v4(),
299 );
300
301 assert!(result.is_err());
303 assert_eq!(
304 result.unwrap_err(),
305 "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
306 );
307 }
308
309 #[test]
310 fn test_mandate_start_after_end_fails() {
311 let start = Utc::now();
313 let end = start - Duration::days(10); let result = BoardMember::new(
317 Uuid::new_v4(),
318 Uuid::new_v4(),
319 BoardPosition::President,
320 start,
321 end,
322 Uuid::new_v4(),
323 );
324
325 assert!(result.is_err());
327 assert_eq!(
328 result.unwrap_err(),
329 "Mandate start date must be before end date"
330 );
331 }
332
333 #[test]
334 fn test_is_active_mandate() {
335 let start = Utc::now() - Duration::days(10); let end = Utc::now() + Duration::days(355); let member = BoardMember::new(
339 Uuid::new_v4(),
340 Uuid::new_v4(),
341 BoardPosition::Member,
342 start,
343 end,
344 Uuid::new_v4(),
345 )
346 .unwrap();
347
348 assert!(member.is_active());
350 }
351
352 #[test]
353 fn test_is_not_active_future_mandate() {
354 let start = Utc::now() + Duration::days(10); let end = start + Duration::days(365);
357 let member = BoardMember::new(
358 Uuid::new_v4(),
359 Uuid::new_v4(),
360 BoardPosition::Member,
361 start,
362 end,
363 Uuid::new_v4(),
364 )
365 .unwrap();
366
367 assert!(!member.is_active());
369 }
370
371 #[test]
372 fn test_days_remaining_calculation() {
373 let start = Utc::now() - Duration::days(300); let end = start + Duration::days(365); let member = BoardMember::new(
377 Uuid::new_v4(),
378 Uuid::new_v4(),
379 BoardPosition::Treasurer,
380 start,
381 end,
382 Uuid::new_v4(),
383 )
384 .unwrap();
385
386 let remaining = member.days_remaining();
388
389 assert!((64..=66).contains(&remaining)); }
392
393 #[test]
394 fn test_days_remaining_expired_returns_zero() {
395 let start = Utc::now() - Duration::days(400); let end = start + Duration::days(365); let member = BoardMember::new(
399 Uuid::new_v4(),
400 Uuid::new_v4(),
401 BoardPosition::Member,
402 start,
403 end,
404 Uuid::new_v4(),
405 )
406 .unwrap();
407
408 let remaining = member.days_remaining();
410
411 assert_eq!(remaining, 0);
413 }
414
415 #[test]
416 fn test_expires_soon_true() {
417 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365); let member = BoardMember::new(
421 Uuid::new_v4(),
422 Uuid::new_v4(),
423 BoardPosition::President,
424 start,
425 end,
426 Uuid::new_v4(),
427 )
428 .unwrap();
429
430 assert!(member.expires_soon());
432 }
433
434 #[test]
435 fn test_expires_soon_false_far_expiration() {
436 let start = Utc::now() - Duration::days(100); let end = start + Duration::days(365); let member = BoardMember::new(
440 Uuid::new_v4(),
441 Uuid::new_v4(),
442 BoardPosition::Member,
443 start,
444 end,
445 Uuid::new_v4(),
446 )
447 .unwrap();
448
449 assert!(!member.expires_soon());
451 }
452
453 #[test]
454 fn test_extend_mandate_success() {
455 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365);
458 let new_meeting_id = Uuid::new_v4();
459 let mut member = BoardMember::new(
460 Uuid::new_v4(),
461 Uuid::new_v4(),
462 BoardPosition::President,
463 start,
464 end,
465 Uuid::new_v4(),
466 )
467 .unwrap();
468
469 let original_end = member.mandate_end;
470
471 let result = member.extend_mandate(365, new_meeting_id);
473
474 assert!(result.is_ok());
476 assert_eq!(member.mandate_start, original_end);
477 assert_eq!(member.mandate_end, original_end + Duration::days(365));
478 assert_eq!(member.elected_by_meeting_id, new_meeting_id);
479 }
480
481 #[test]
482 fn test_extend_mandate_too_long_fails() {
483 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365);
488 let new_meeting_id = Uuid::new_v4();
489 let mut member = BoardMember::new(
490 Uuid::new_v4(),
491 Uuid::new_v4(),
492 BoardPosition::President,
493 start,
494 end,
495 Uuid::new_v4(),
496 )
497 .unwrap();
498
499 let result = member.extend_mandate(730, new_meeting_id);
501
502 assert!(result.is_err());
504 assert_eq!(
505 result.unwrap_err(),
506 "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
507 );
508 }
509
510 #[test]
511 fn test_extend_mandate_far_too_long_fails() {
512 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365);
516 let new_meeting_id = Uuid::new_v4();
517 let mut member = BoardMember::new(
518 Uuid::new_v4(),
519 Uuid::new_v4(),
520 BoardPosition::President,
521 start,
522 end,
523 Uuid::new_v4(),
524 )
525 .unwrap();
526
527 let result = member.extend_mandate(1100, new_meeting_id);
529
530 assert!(result.is_err());
532 assert_eq!(
533 result.unwrap_err(),
534 "Mandate duration must be approximately 1 year (Art. 3.90 CC)"
535 );
536 }
537
538 #[test]
539 fn test_extend_mandate_fails_too_early() {
540 let start = Utc::now() - Duration::days(100); let end = start + Duration::days(365);
543 let mut member = BoardMember::new(
544 Uuid::new_v4(),
545 Uuid::new_v4(),
546 BoardPosition::Member,
547 start,
548 end,
549 Uuid::new_v4(),
550 )
551 .unwrap();
552
553 let result = member.extend_mandate(365, Uuid::new_v4());
555
556 assert!(result.is_err());
558 assert_eq!(
559 result.unwrap_err(),
560 "Cannot extend mandate more than 60 days before expiration"
561 );
562 }
563
564 #[test]
569 fn happy_resign_sets_mandate_end_to_now() {
570 let start = Utc::now() - Duration::days(100);
571 let end = start + Duration::days(365);
572 let mut member = BoardMember::new(
573 Uuid::new_v4(),
574 Uuid::new_v4(),
575 BoardPosition::Member,
576 start,
577 end,
578 Uuid::new_v4(),
579 )
580 .unwrap();
581
582 let resignation_instant = Utc::now();
583 member.resign(resignation_instant);
584
585 assert_eq!(member.mandate_end, resignation_instant);
586 }
587
588 #[test]
591 fn edge_resign_loses_rights_exactly_at_mandate_end() {
592 let start = Utc::now() - Duration::days(100);
593 let end = start + Duration::days(365);
594 let mut member = BoardMember::new(
595 Uuid::new_v4(),
596 Uuid::new_v4(),
597 BoardPosition::Member,
598 start,
599 end,
600 Uuid::new_v4(),
601 )
602 .unwrap();
603
604 let resignation_instant = Utc::now();
605 member.resign(resignation_instant);
606
607 assert!(
608 member.is_active_at(resignation_instant - Duration::milliseconds(1)),
609 "juste avant la démission, le mandat était encore actif"
610 );
611 assert!(
612 !member.is_active_at(resignation_instant),
613 "au moment exact de la démission, le mandat est déjà échu"
614 );
615 }
616
617 #[test]
620 fn edge_resign_after_mandate_already_ended_does_not_extend_it() {
621 let start = Utc::now() - Duration::days(400);
622 let end = start + Duration::days(365); let mut member = BoardMember::new(
624 Uuid::new_v4(),
625 Uuid::new_v4(),
626 BoardPosition::Member,
627 start,
628 end,
629 Uuid::new_v4(),
630 )
631 .unwrap();
632
633 member.resign(Utc::now());
634
635 assert_eq!(member.mandate_end, end, "la fin de mandat n'a pas bougé");
636 }
637
638 #[test]
639 fn test_board_position_display() {
640 assert_eq!(BoardPosition::President.to_string(), "president");
641 assert_eq!(BoardPosition::Treasurer.to_string(), "treasurer");
642 assert_eq!(BoardPosition::Member.to_string(), "member");
643 }
644
645 #[test]
646 fn test_board_position_from_str() {
647 assert_eq!(
648 "president".parse::<BoardPosition>().unwrap(),
649 BoardPosition::President
650 );
651 assert_eq!(
652 "President".parse::<BoardPosition>().unwrap(),
653 BoardPosition::President
654 );
655 assert_eq!(
656 "TREASURER".parse::<BoardPosition>().unwrap(),
657 BoardPosition::Treasurer
658 );
659 assert_eq!(
660 "member".parse::<BoardPosition>().unwrap(),
661 BoardPosition::Member
662 );
663
664 assert!("invalid".parse::<BoardPosition>().is_err());
665 }
666}