1use crate::application::dto::{
2 BoardMemberResponseDto, BoardStatsDto, CreateBoardMemberDto, RenewMandateDto,
3};
4use crate::application::ports::{BoardMemberRepository, BuildingRepository};
5use crate::domain::entities::{BoardMember, BoardPosition};
6use chrono::{DateTime, Utc};
7use serde::Serialize;
8use std::sync::Arc;
9use uuid::Uuid;
10
11#[derive(Serialize)]
13pub struct ActiveMandateWithBuilding {
14 pub id: Uuid,
15 pub building_id: Uuid,
16 pub building_name: String,
17 pub building_address: String,
18 pub position: String,
19 pub mandate_start: String,
20 pub mandate_end: String,
21 pub days_remaining: i64,
22 pub expires_soon: bool,
23}
24
25pub struct BoardMemberUseCases {
26 repository: Arc<dyn BoardMemberRepository>,
27 building_repository: Arc<dyn BuildingRepository>,
28}
29
30impl BoardMemberUseCases {
31 pub fn new(
32 repository: Arc<dyn BoardMemberRepository>,
33 building_repository: Arc<dyn BuildingRepository>,
34 ) -> Self {
35 Self {
36 repository,
37 building_repository,
38 }
39 }
40
41 pub async fn elect_board_member(
44 &self,
45 dto: CreateBoardMemberDto,
46 ) -> Result<BoardMemberResponseDto, String> {
47 let owner_id =
49 Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
50 let building_id = Uuid::parse_str(&dto.building_id)
51 .map_err(|_| "Invalid building_id format".to_string())?;
52 let elected_by_meeting_id = Uuid::parse_str(&dto.elected_by_meeting_id)
53 .map_err(|_| "Invalid elected_by_meeting_id format".to_string())?;
54
55 let building = self
57 .building_repository
58 .find_by_id(building_id)
59 .await?
60 .ok_or_else(|| "Building not found".to_string())?;
61
62 if building.total_units <= 20 {
63 return Err(
64 "Board of directors is only required for buildings with more than 20 units"
65 .to_string(),
66 );
67 }
68
69 let position: BoardPosition = dto
71 .position
72 .parse()
73 .map_err(|e| format!("Invalid position: {}", e))?;
74
75 let mandate_start = DateTime::parse_from_rfc3339(&dto.mandate_start)
77 .map_err(|_| "Invalid mandate_start format".to_string())?
78 .with_timezone(&Utc);
79
80 let mandate_end = DateTime::parse_from_rfc3339(&dto.mandate_end)
81 .map_err(|_| "Invalid mandate_end format".to_string())?
82 .with_timezone(&Utc);
83
84 let board_member = BoardMember::new(
86 owner_id,
87 building_id,
88 position,
89 mandate_start,
90 mandate_end,
91 elected_by_meeting_id,
92 )?;
93
94 let created = self.repository.create(&board_member).await?;
96
97 Ok(self.to_response_dto(&created))
98 }
99
100 pub async fn get_board_member(
102 &self,
103 id: Uuid,
104 ) -> Result<Option<BoardMemberResponseDto>, String> {
105 let member = self.repository.find_by_id(id).await?;
106 Ok(member.map(|m| self.to_response_dto(&m)))
107 }
108
109 pub async fn list_active_board_members(
111 &self,
112 building_id: Uuid,
113 ) -> Result<Vec<BoardMemberResponseDto>, String> {
114 let members = self.repository.find_active_by_building(building_id).await?;
115 Ok(members.iter().map(|m| self.to_response_dto(m)).collect())
116 }
117
118 pub async fn list_all_board_members(
120 &self,
121 building_id: Uuid,
122 ) -> Result<Vec<BoardMemberResponseDto>, String> {
123 let members = self.repository.find_by_building(building_id).await?;
124 Ok(members.iter().map(|m| self.to_response_dto(m)).collect())
125 }
126
127 pub async fn renew_mandate(
129 &self,
130 id: Uuid,
131 dto: RenewMandateDto,
132 ) -> Result<BoardMemberResponseDto, String> {
133 let mut member = self
134 .repository
135 .find_by_id(id)
136 .await?
137 .ok_or_else(|| "Board member not found".to_string())?;
138
139 let new_meeting_id = Uuid::parse_str(&dto.new_elected_by_meeting_id)
140 .map_err(|_| "Invalid meeting_id format".to_string())?;
141
142 member.extend_mandate(dto.mandate_duration_days, new_meeting_id)?;
144
145 let updated = self.repository.update(&member).await?;
147
148 Ok(self.to_response_dto(&updated))
149 }
150
151 pub async fn remove_board_member(&self, id: Uuid) -> Result<bool, String> {
153 self.repository.delete(id).await
154 }
155
156 pub async fn get_board_stats(&self, building_id: Uuid) -> Result<BoardStatsDto, String> {
158 let all_members = self.repository.find_by_building(building_id).await?;
159 let active_members = self.repository.find_active_by_building(building_id).await?;
160 let expiring_soon = self.repository.find_expiring_soon(building_id, 60).await?;
161
162 let has_president = active_members
163 .iter()
164 .any(|m| m.position == BoardPosition::President);
165 let has_treasurer = active_members
166 .iter()
167 .any(|m| m.position == BoardPosition::Treasurer);
168
169 Ok(BoardStatsDto {
170 building_id: building_id.to_string(),
171 total_members: all_members.len() as i64,
172 active_members: active_members.len() as i64,
173 expiring_soon: expiring_soon.len() as i64,
174 has_president,
175 has_treasurer,
176 })
177 }
178
179 pub async fn get_active_mandates_for_owner(
181 &self,
182 owner_id: Uuid,
183 organization_id: Uuid,
184 ) -> Result<Vec<ActiveMandateWithBuilding>, String> {
185 let all_mandates = self.repository.find_by_owner(owner_id).await?;
186 let now = Utc::now();
187 let mut result = Vec::new();
188
189 for member in all_mandates {
190 if !member.is_active() {
191 continue;
192 }
193 let building = match self
194 .building_repository
195 .find_by_id(member.building_id)
196 .await?
197 {
198 Some(b) => b,
199 None => continue,
200 };
201 if building.organization_id != organization_id {
202 continue;
203 }
204 let days_remaining = (member.mandate_end - now).num_days();
205 result.push(ActiveMandateWithBuilding {
206 id: member.id,
207 building_id: member.building_id,
208 building_name: building.name.clone(),
209 building_address: building.address.clone(),
210 position: member.position.to_string(),
211 mandate_start: member.mandate_start.format("%Y-%m-%d").to_string(),
212 mandate_end: member.mandate_end.format("%Y-%m-%d").to_string(),
213 days_remaining,
214 expires_soon: days_remaining > 0 && days_remaining <= 90,
215 });
216 }
217 Ok(result)
218 }
219
220 pub async fn has_active_board_mandate(
223 &self,
224 owner_id: Uuid,
225 building_id: Uuid,
226 ) -> Result<bool, String> {
227 self.repository
228 .has_active_mandate(owner_id, building_id)
229 .await
230 }
231
232 fn to_response_dto(&self, member: &BoardMember) -> BoardMemberResponseDto {
234 BoardMemberResponseDto {
235 id: member.id.to_string(),
236 owner_id: member.owner_id.to_string(),
237 building_id: member.building_id.to_string(),
238 position: member.position.to_string(),
239 mandate_start: member.mandate_start.to_rfc3339(),
240 mandate_end: member.mandate_end.to_rfc3339(),
241 elected_by_meeting_id: member.elected_by_meeting_id.to_string(),
242 is_active: member.is_active(),
243 days_remaining: member.days_remaining(),
244 expires_soon: member.expires_soon(),
245 created_at: member.created_at.to_rfc3339(),
246 updated_at: member.updated_at.to_rfc3339(),
247 }
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254 use crate::domain::entities::Building;
255 use chrono::Duration;
256 use mockall::mock;
257 use mockall::predicate::*;
258
259 mock! {
261 pub BoardMemberRepo {}
262
263 #[async_trait::async_trait]
264 impl BoardMemberRepository for BoardMemberRepo {
265 async fn create(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
266 async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardMember>, String>;
267 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
268 async fn find_active_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
269 async fn find_expiring_soon(&self, building_id: Uuid, days_threshold: i32) -> Result<Vec<BoardMember>, String>;
270 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<BoardMember>, String>;
271 async fn find_by_owner_and_building(&self, owner_id: Uuid, building_id: Uuid) -> Result<Option<BoardMember>, String>;
272 async fn has_active_mandate(&self, owner_id: Uuid, building_id: Uuid) -> Result<bool, String>;
273 async fn update(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
274 async fn delete(&self, id: Uuid) -> Result<bool, String>;
275 async fn count_active_by_building(&self, building_id: Uuid) -> Result<i64, String>;
276 }
277 }
278
279 mock! {
281 pub BuildingRepo {}
282
283 #[async_trait::async_trait]
284 impl BuildingRepository for BuildingRepo {
285 async fn create(&self, building: &Building) -> Result<Building, String>;
286 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
287 async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
288 async fn find_all(&self) -> Result<Vec<Building>, String>;
289 async fn find_all_paginated(
290 &self,
291 page_request: &crate::application::dto::PageRequest,
292 filters: &crate::application::dto::BuildingFilters,
293 ) -> Result<(Vec<Building>, i64), String>;
294 async fn update(&self, building: &Building) -> Result<Building, String>;
295 async fn delete(&self, id: Uuid) -> Result<bool, String>;
296 }
297 }
298
299 fn create_test_building(total_units: i32) -> Building {
300 Building::new(
301 Uuid::new_v4(),
302 "Test Building".to_string(),
303 "123 Test St".to_string(),
304 "Brussels".to_string(),
305 "1000".to_string(),
306 "Belgium".to_string(),
307 total_units,
308 1000,
309 Some(2020),
310 )
311 .unwrap()
312 }
313
314 #[tokio::test]
315 async fn test_elect_board_member_success() {
316 let mut mock_board_repo = MockBoardMemberRepo::new();
318 let mut mock_building_repo = MockBuildingRepo::new();
319
320 let building = create_test_building(25); let building_id = building.id;
322
323 mock_building_repo
324 .expect_find_by_id()
325 .with(eq(building_id))
326 .times(1)
327 .returning(move |_| Ok(Some(create_test_building(25))));
328
329 mock_board_repo
330 .expect_create()
331 .times(1)
332 .returning(|member| Ok(member.clone()));
333
334 let use_cases =
335 BoardMemberUseCases::new(Arc::new(mock_board_repo), Arc::new(mock_building_repo));
336
337 let dto = CreateBoardMemberDto {
338 owner_id: Uuid::new_v4().to_string(),
339 building_id: building_id.to_string(),
340 position: "president".to_string(),
341 mandate_start: Utc::now().to_rfc3339(),
342 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
343 elected_by_meeting_id: Uuid::new_v4().to_string(),
344 };
345
346 let result = use_cases.elect_board_member(dto).await;
348
349 assert!(result.is_ok());
351 let response = result.unwrap();
352 assert_eq!(response.position, "president");
353 assert!(response.is_active);
354 }
355
356 #[tokio::test]
357 async fn test_elect_board_member_fails_building_not_found() {
358 let mock_board_repo = MockBoardMemberRepo::new();
360 let mut mock_building_repo = MockBuildingRepo::new();
361
362 let building_id = Uuid::new_v4();
363
364 mock_building_repo
365 .expect_find_by_id()
366 .with(eq(building_id))
367 .times(1)
368 .returning(|_| Ok(None)); let use_cases =
371 BoardMemberUseCases::new(Arc::new(mock_board_repo), Arc::new(mock_building_repo));
372
373 let dto = CreateBoardMemberDto {
374 owner_id: Uuid::new_v4().to_string(),
375 building_id: building_id.to_string(),
376 position: "president".to_string(),
377 mandate_start: Utc::now().to_rfc3339(),
378 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
379 elected_by_meeting_id: Uuid::new_v4().to_string(),
380 };
381
382 let result = use_cases.elect_board_member(dto).await;
384
385 assert!(result.is_err());
387 assert_eq!(result.unwrap_err(), "Building not found");
388 }
389
390 #[tokio::test]
391 async fn test_elect_board_member_fails_building_too_small() {
392 let mock_board_repo = MockBoardMemberRepo::new();
394 let mut mock_building_repo = MockBuildingRepo::new();
395
396 let building = create_test_building(15); let building_id = building.id;
398
399 mock_building_repo
400 .expect_find_by_id()
401 .with(eq(building_id))
402 .times(1)
403 .returning(move |_| Ok(Some(create_test_building(15))));
404
405 let use_cases =
406 BoardMemberUseCases::new(Arc::new(mock_board_repo), Arc::new(mock_building_repo));
407
408 let dto = CreateBoardMemberDto {
409 owner_id: Uuid::new_v4().to_string(),
410 building_id: building_id.to_string(),
411 position: "president".to_string(),
412 mandate_start: Utc::now().to_rfc3339(),
413 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
414 elected_by_meeting_id: Uuid::new_v4().to_string(),
415 };
416
417 let result = use_cases.elect_board_member(dto).await;
419
420 assert!(result.is_err());
422 assert_eq!(
423 result.unwrap_err(),
424 "Board of directors is only required for buildings with more than 20 units"
425 );
426 }
427
428 #[tokio::test]
429 async fn test_get_board_stats() {
430 let mut mock_board_repo = MockBoardMemberRepo::new();
432 let mock_building_repo = MockBuildingRepo::new();
433
434 let building_id = Uuid::new_v4();
435 let user_id = Uuid::new_v4();
436
437 let president = BoardMember::new(
439 user_id,
440 building_id,
441 BoardPosition::President,
442 Utc::now() - Duration::days(100),
443 Utc::now() + Duration::days(265),
444 Uuid::new_v4(),
445 )
446 .unwrap();
447
448 let treasurer = BoardMember::new(
449 Uuid::new_v4(),
450 building_id,
451 BoardPosition::Treasurer,
452 Utc::now() - Duration::days(320),
453 Utc::now() + Duration::days(45), Uuid::new_v4(),
455 )
456 .unwrap();
457
458 let all_members = vec![president.clone(), treasurer.clone()];
459 let active_members = vec![president.clone(), treasurer.clone()];
460 let expiring = vec![treasurer.clone()];
461
462 mock_board_repo
463 .expect_find_by_building()
464 .with(eq(building_id))
465 .times(1)
466 .return_once(move |_| Ok(all_members));
467
468 mock_board_repo
469 .expect_find_active_by_building()
470 .with(eq(building_id))
471 .times(1)
472 .return_once(move |_| Ok(active_members));
473
474 mock_board_repo
475 .expect_find_expiring_soon()
476 .with(eq(building_id), eq(60))
477 .times(1)
478 .return_once(move |_, _| Ok(expiring));
479
480 let use_cases =
481 BoardMemberUseCases::new(Arc::new(mock_board_repo), Arc::new(mock_building_repo));
482
483 let result = use_cases.get_board_stats(building_id).await;
485
486 assert!(result.is_ok());
488 let stats = result.unwrap();
489 assert_eq!(stats.total_members, 2);
490 assert_eq!(stats.active_members, 2);
491 assert_eq!(stats.expiring_soon, 1);
492 assert!(stats.has_president);
493 assert!(stats.has_treasurer);
494 }
495
496 #[tokio::test]
497 async fn test_renew_mandate_success() {
498 let mut mock_board_repo = MockBoardMemberRepo::new();
500 let mock_building_repo = MockBuildingRepo::new();
501
502 let member_id = Uuid::new_v4();
503 let building_id = Uuid::new_v4();
504
505 let member = BoardMember::new(
507 Uuid::new_v4(),
508 building_id,
509 BoardPosition::President,
510 Utc::now() - Duration::days(320),
511 Utc::now() + Duration::days(45), Uuid::new_v4(),
513 )
514 .unwrap();
515
516 let member_clone = member.clone();
517
518 mock_board_repo
519 .expect_find_by_id()
520 .with(eq(member_id))
521 .times(1)
522 .return_once(move |_| Ok(Some(member_clone)));
523
524 mock_board_repo
525 .expect_update()
526 .times(1)
527 .returning(|m| Ok(m.clone()));
528
529 let use_cases =
530 BoardMemberUseCases::new(Arc::new(mock_board_repo), Arc::new(mock_building_repo));
531
532 let dto = RenewMandateDto {
533 new_elected_by_meeting_id: Uuid::new_v4().to_string(),
534 mandate_duration_days: 1095,
535 };
536
537 let result = use_cases.renew_mandate(member_id, dto).await;
539
540 assert!(result.is_ok());
542 let response = result.unwrap();
543 assert!(response.days_remaining > 300); }
545}