koprogo_api/domain/entities/
board_member.rs1use 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(
55 owner_id: Uuid,
56 building_id: Uuid,
57 position: BoardPosition,
58 mandate_start: DateTime<Utc>,
59 mandate_end: DateTime<Utc>,
60 elected_by_meeting_id: Uuid,
61 ) -> Result<Self, String> {
62 if mandate_start >= mandate_end {
64 return Err("Mandate start date must be before end date".to_string());
65 }
66
67 let duration_days = (mandate_end - mandate_start).num_days();
69 if !(330..=395).contains(&duration_days) {
70 return Err("Mandate duration must be approximately 1 year (11-13 months)".to_string());
71 }
72
73 let now = Utc::now();
74 Ok(Self {
75 id: Uuid::new_v4(),
76 owner_id,
77 building_id,
78 position,
79 mandate_start,
80 mandate_end,
81 elected_by_meeting_id,
82 created_at: now,
83 updated_at: now,
84 })
85 }
86
87 pub fn is_active(&self) -> bool {
89 let now = Utc::now();
90 now >= self.mandate_start && now <= self.mandate_end
91 }
92
93 pub fn days_remaining(&self) -> i64 {
96 let now = Utc::now();
97 if now > self.mandate_end {
98 return 0;
99 }
100 (self.mandate_end - now).num_days()
101 }
102
103 pub fn expires_soon(&self) -> bool {
105 self.days_remaining() > 0 && self.days_remaining() < 60
106 }
107
108 pub fn extend_mandate(&mut self, new_elected_by_meeting_id: Uuid) -> Result<(), String> {
110 if !self.expires_soon() && self.is_active() {
111 return Err("Cannot extend mandate more than 60 days before expiration".to_string());
112 }
113
114 self.mandate_start = self.mandate_end;
116 self.mandate_end = self.mandate_start + Duration::days(365);
117 self.elected_by_meeting_id = new_elected_by_meeting_id;
118 self.updated_at = Utc::now();
119
120 Ok(())
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn test_create_board_member_success() {
130 let owner_id = Uuid::new_v4();
132 let building_id = Uuid::new_v4();
133 let meeting_id = Uuid::new_v4();
134 let start = Utc::now();
135 let end = start + Duration::days(365);
136
137 let result = BoardMember::new(
139 owner_id,
140 building_id,
141 BoardPosition::President,
142 start,
143 end,
144 meeting_id,
145 );
146
147 assert!(result.is_ok());
149 let member = result.unwrap();
150 assert_eq!(member.owner_id, owner_id);
151 assert_eq!(member.building_id, building_id);
152 assert_eq!(member.position, BoardPosition::President);
153 assert_eq!(member.mandate_start, start);
154 assert_eq!(member.mandate_end, end);
155 assert_eq!(member.elected_by_meeting_id, meeting_id);
156 }
157
158 #[test]
159 fn test_mandate_duration_one_year() {
160 let start = Utc::now();
162 let end = start + Duration::days(365);
163
164 let result = BoardMember::new(
166 Uuid::new_v4(),
167 Uuid::new_v4(),
168 BoardPosition::Member,
169 start,
170 end,
171 Uuid::new_v4(),
172 );
173
174 assert!(result.is_ok());
176 }
177
178 #[test]
179 fn test_mandate_duration_too_short_fails() {
180 let start = Utc::now();
182 let end = start + Duration::days(300); let result = BoardMember::new(
186 Uuid::new_v4(),
187 Uuid::new_v4(),
188 BoardPosition::Member,
189 start,
190 end,
191 Uuid::new_v4(),
192 );
193
194 assert!(result.is_err());
196 assert_eq!(
197 result.unwrap_err(),
198 "Mandate duration must be approximately 1 year (11-13 months)"
199 );
200 }
201
202 #[test]
203 fn test_mandate_duration_too_long_fails() {
204 let start = Utc::now();
206 let end = start + Duration::days(400); let result = BoardMember::new(
210 Uuid::new_v4(),
211 Uuid::new_v4(),
212 BoardPosition::Member,
213 start,
214 end,
215 Uuid::new_v4(),
216 );
217
218 assert!(result.is_err());
220 assert_eq!(
221 result.unwrap_err(),
222 "Mandate duration must be approximately 1 year (11-13 months)"
223 );
224 }
225
226 #[test]
227 fn test_mandate_start_after_end_fails() {
228 let start = Utc::now();
230 let end = start - Duration::days(10); let result = BoardMember::new(
234 Uuid::new_v4(),
235 Uuid::new_v4(),
236 BoardPosition::President,
237 start,
238 end,
239 Uuid::new_v4(),
240 );
241
242 assert!(result.is_err());
244 assert_eq!(
245 result.unwrap_err(),
246 "Mandate start date must be before end date"
247 );
248 }
249
250 #[test]
251 fn test_is_active_mandate() {
252 let start = Utc::now() - Duration::days(10); let end = Utc::now() + Duration::days(355); let member = BoardMember::new(
256 Uuid::new_v4(),
257 Uuid::new_v4(),
258 BoardPosition::Member,
259 start,
260 end,
261 Uuid::new_v4(),
262 )
263 .unwrap();
264
265 assert!(member.is_active());
267 }
268
269 #[test]
270 fn test_is_not_active_future_mandate() {
271 let start = Utc::now() + Duration::days(10); let end = start + Duration::days(365);
274 let member = BoardMember::new(
275 Uuid::new_v4(),
276 Uuid::new_v4(),
277 BoardPosition::Member,
278 start,
279 end,
280 Uuid::new_v4(),
281 )
282 .unwrap();
283
284 assert!(!member.is_active());
286 }
287
288 #[test]
289 fn test_days_remaining_calculation() {
290 let start = Utc::now() - Duration::days(300); let end = start + Duration::days(365); let member = BoardMember::new(
294 Uuid::new_v4(),
295 Uuid::new_v4(),
296 BoardPosition::Treasurer,
297 start,
298 end,
299 Uuid::new_v4(),
300 )
301 .unwrap();
302
303 let remaining = member.days_remaining();
305
306 assert!((64..=66).contains(&remaining)); }
309
310 #[test]
311 fn test_days_remaining_expired_returns_zero() {
312 let start = Utc::now() - Duration::days(400); let end = start + Duration::days(365); let member = BoardMember::new(
316 Uuid::new_v4(),
317 Uuid::new_v4(),
318 BoardPosition::Member,
319 start,
320 end,
321 Uuid::new_v4(),
322 )
323 .unwrap();
324
325 let remaining = member.days_remaining();
327
328 assert_eq!(remaining, 0);
330 }
331
332 #[test]
333 fn test_expires_soon_true() {
334 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365); let member = BoardMember::new(
338 Uuid::new_v4(),
339 Uuid::new_v4(),
340 BoardPosition::President,
341 start,
342 end,
343 Uuid::new_v4(),
344 )
345 .unwrap();
346
347 assert!(member.expires_soon());
349 }
350
351 #[test]
352 fn test_expires_soon_false_far_expiration() {
353 let start = Utc::now() - Duration::days(100); let end = start + Duration::days(365); let member = BoardMember::new(
357 Uuid::new_v4(),
358 Uuid::new_v4(),
359 BoardPosition::Member,
360 start,
361 end,
362 Uuid::new_v4(),
363 )
364 .unwrap();
365
366 assert!(!member.expires_soon());
368 }
369
370 #[test]
371 fn test_extend_mandate_success() {
372 let start = Utc::now() - Duration::days(320); let end = start + Duration::days(365);
375 let new_meeting_id = Uuid::new_v4();
376 let mut member = BoardMember::new(
377 Uuid::new_v4(),
378 Uuid::new_v4(),
379 BoardPosition::President,
380 start,
381 end,
382 Uuid::new_v4(),
383 )
384 .unwrap();
385
386 let original_end = member.mandate_end;
387
388 let result = member.extend_mandate(new_meeting_id);
390
391 assert!(result.is_ok());
393 assert_eq!(member.mandate_start, original_end);
394 assert_eq!(member.mandate_end, original_end + Duration::days(365));
395 assert_eq!(member.elected_by_meeting_id, new_meeting_id);
396 }
397
398 #[test]
399 fn test_extend_mandate_fails_too_early() {
400 let start = Utc::now() - Duration::days(100); let end = start + Duration::days(365);
403 let mut member = BoardMember::new(
404 Uuid::new_v4(),
405 Uuid::new_v4(),
406 BoardPosition::Member,
407 start,
408 end,
409 Uuid::new_v4(),
410 )
411 .unwrap();
412
413 let result = member.extend_mandate(Uuid::new_v4());
415
416 assert!(result.is_err());
418 assert_eq!(
419 result.unwrap_err(),
420 "Cannot extend mandate more than 60 days before expiration"
421 );
422 }
423
424 #[test]
425 fn test_board_position_display() {
426 assert_eq!(BoardPosition::President.to_string(), "president");
427 assert_eq!(BoardPosition::Treasurer.to_string(), "treasurer");
428 assert_eq!(BoardPosition::Member.to_string(), "member");
429 }
430
431 #[test]
432 fn test_board_position_from_str() {
433 assert_eq!(
434 "president".parse::<BoardPosition>().unwrap(),
435 BoardPosition::President
436 );
437 assert_eq!(
438 "President".parse::<BoardPosition>().unwrap(),
439 BoardPosition::President
440 );
441 assert_eq!(
442 "TREASURER".parse::<BoardPosition>().unwrap(),
443 BoardPosition::Treasurer
444 );
445 assert_eq!(
446 "member".parse::<BoardPosition>().unwrap(),
447 BoardPosition::Member
448 );
449
450 assert!("invalid".parse::<BoardPosition>().is_err());
451 }
452}