1use crate::application::dto::{
2 BoardMemberResponseDto, BoardStatsDto, CreateBoardMemberDto, RenewMandateDto,
3};
4use crate::application::ports::{AcpRepository, 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 acp_repository: Arc<dyn AcpRepository>,
29}
30
31impl BoardMemberUseCases {
32 pub fn new(
33 repository: Arc<dyn BoardMemberRepository>,
34 building_repository: Arc<dyn BuildingRepository>,
35 acp_repository: Arc<dyn AcpRepository>,
36 ) -> Self {
37 Self {
38 repository,
39 building_repository,
40 acp_repository,
41 }
42 }
43
44 pub async fn elect_board_member(
47 &self,
48 dto: CreateBoardMemberDto,
49 ) -> Result<BoardMemberResponseDto, String> {
50 let owner_id =
52 Uuid::parse_str(&dto.owner_id).map_err(|_| "Invalid owner_id format".to_string())?;
53 let building_id = Uuid::parse_str(&dto.building_id)
54 .map_err(|_| "Invalid building_id format".to_string())?;
55 let elected_by_meeting_id = Uuid::parse_str(&dto.elected_by_meeting_id)
56 .map_err(|_| "Invalid elected_by_meeting_id format".to_string())?;
57
58 let building = self
60 .building_repository
61 .find_by_id(building_id)
62 .await?
63 .ok_or_else(|| "Building not found".to_string())?;
64
65 if building.total_units <= 20 {
66 return Err(
67 "Board of directors is only required for buildings with more than 20 units"
68 .to_string(),
69 );
70 }
71
72 let position: BoardPosition = dto
74 .position
75 .parse()
76 .map_err(|e| format!("Invalid position: {}", e))?;
77
78 let mandate_start = DateTime::parse_from_rfc3339(&dto.mandate_start)
80 .map_err(|_| "Invalid mandate_start format".to_string())?
81 .with_timezone(&Utc);
82
83 let mandate_end = DateTime::parse_from_rfc3339(&dto.mandate_end)
84 .map_err(|_| "Invalid mandate_end format".to_string())?
85 .with_timezone(&Utc);
86
87 let board_member = BoardMember::new(
89 owner_id,
90 building_id,
91 position,
92 mandate_start,
93 mandate_end,
94 elected_by_meeting_id,
95 )?;
96
97 let created = self.repository.create(&board_member).await?;
99
100 Ok(self.to_response_dto(&created))
101 }
102
103 pub async fn get_board_member(
105 &self,
106 id: Uuid,
107 ) -> Result<Option<BoardMemberResponseDto>, String> {
108 let member = self.repository.find_by_id(id).await?;
109 Ok(member.map(|m| self.to_response_dto(&m)))
110 }
111
112 pub async fn list_active_board_members(
114 &self,
115 building_id: Uuid,
116 ) -> Result<Vec<BoardMemberResponseDto>, String> {
117 let members = self.repository.find_active_by_building(building_id).await?;
118 Ok(members.iter().map(|m| self.to_response_dto(m)).collect())
119 }
120
121 pub async fn list_all_board_members(
123 &self,
124 building_id: Uuid,
125 ) -> Result<Vec<BoardMemberResponseDto>, String> {
126 let members = self.repository.find_by_building(building_id).await?;
127 Ok(members.iter().map(|m| self.to_response_dto(m)).collect())
128 }
129
130 pub async fn renew_mandate(
132 &self,
133 id: Uuid,
134 dto: RenewMandateDto,
135 ) -> Result<BoardMemberResponseDto, String> {
136 let mut member = self
137 .repository
138 .find_by_id(id)
139 .await?
140 .ok_or_else(|| "Board member not found".to_string())?;
141
142 let new_meeting_id = Uuid::parse_str(&dto.new_elected_by_meeting_id)
143 .map_err(|_| "Invalid meeting_id format".to_string())?;
144
145 member.extend_mandate(dto.mandate_duration_days, new_meeting_id)?;
147
148 let updated = self.repository.update(&member).await?;
150
151 Ok(self.to_response_dto(&updated))
152 }
153
154 pub async fn remove_board_member(&self, id: Uuid) -> Result<bool, String> {
156 self.repository.delete(id).await
157 }
158
159 pub async fn get_board_stats(&self, building_id: Uuid) -> Result<BoardStatsDto, String> {
161 let all_members = self.repository.find_by_building(building_id).await?;
162 let active_members = self.repository.find_active_by_building(building_id).await?;
163 let expiring_soon = self.repository.find_expiring_soon(building_id, 60).await?;
164
165 let has_president = active_members
166 .iter()
167 .any(|m| m.position == BoardPosition::President);
168 let has_treasurer = active_members
169 .iter()
170 .any(|m| m.position == BoardPosition::Treasurer);
171
172 Ok(BoardStatsDto {
173 building_id: building_id.to_string(),
174 total_members: all_members.len() as i64,
175 active_members: active_members.len() as i64,
176 expiring_soon: expiring_soon.len() as i64,
177 has_president,
178 has_treasurer,
179 })
180 }
181
182 pub async fn get_active_mandates_for_owner(
184 &self,
185 owner_id: Uuid,
186 organization_id: Uuid,
187 ) -> Result<Vec<ActiveMandateWithBuilding>, String> {
188 let all_mandates = self.repository.find_by_owner(owner_id).await?;
189 let now = Utc::now();
190 let mut result = Vec::new();
191
192 for member in all_mandates {
193 if !member.is_active() {
194 continue;
195 }
196 let building = match self
197 .building_repository
198 .find_by_id(member.building_id)
199 .await?
200 {
201 Some(b) => b,
202 None => continue,
203 };
204 let acp = match self.acp_repository.find_by_id(building.acp_id).await {
206 Ok(Some(acp)) => acp,
207 _ => continue,
208 };
209 match acp.organization_id {
210 Some(org) if org == organization_id => {}
211 _ => continue,
212 }
213 let days_remaining = (member.mandate_end - now).num_days();
214 result.push(ActiveMandateWithBuilding {
215 id: member.id,
216 building_id: member.building_id,
217 building_name: building.name.clone(),
218 building_address: building.address.clone(),
219 position: member.position.to_string(),
220 mandate_start: member.mandate_start.format("%Y-%m-%d").to_string(),
221 mandate_end: member.mandate_end.format("%Y-%m-%d").to_string(),
222 days_remaining,
223 expires_soon: days_remaining > 0 && days_remaining <= 90,
224 });
225 }
226 Ok(result)
227 }
228
229 pub async fn has_active_board_mandate(
232 &self,
233 owner_id: Uuid,
234 building_id: Uuid,
235 ) -> Result<bool, String> {
236 self.repository
237 .has_active_mandate(owner_id, building_id)
238 .await
239 }
240
241 fn to_response_dto(&self, member: &BoardMember) -> BoardMemberResponseDto {
243 BoardMemberResponseDto {
244 id: member.id.to_string(),
245 owner_id: member.owner_id.to_string(),
246 building_id: member.building_id.to_string(),
247 position: member.position.to_string(),
248 mandate_start: member.mandate_start.to_rfc3339(),
249 mandate_end: member.mandate_end.to_rfc3339(),
250 elected_by_meeting_id: member.elected_by_meeting_id.to_string(),
251 is_active: member.is_active(),
252 days_remaining: member.days_remaining(),
253 expires_soon: member.expires_soon(),
254 created_at: member.created_at.to_rfc3339(),
255 updated_at: member.updated_at.to_rfc3339(),
256 }
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use crate::domain::entities::Building;
264 use chrono::Duration;
265 use mockall::mock;
266 use mockall::predicate::*;
267
268 mock! {
270 pub BoardMemberRepo {}
271
272 #[async_trait::async_trait]
273 impl BoardMemberRepository for BoardMemberRepo {
274 async fn create(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
275 async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardMember>, String>;
276 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
277 async fn find_active_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String>;
278 async fn find_expiring_soon(&self, building_id: Uuid, days_threshold: i32) -> Result<Vec<BoardMember>, String>;
279 async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<BoardMember>, String>;
280 async fn find_by_owner_and_building(&self, owner_id: Uuid, building_id: Uuid) -> Result<Option<BoardMember>, String>;
281 async fn has_active_mandate(&self, owner_id: Uuid, building_id: Uuid) -> Result<bool, String>;
282 async fn update(&self, board_member: &BoardMember) -> Result<BoardMember, String>;
283 async fn delete(&self, id: Uuid) -> Result<bool, String>;
284 async fn count_active_by_building(&self, building_id: Uuid) -> Result<i64, String>;
285 }
286 }
287
288 mock! {
290 pub BuildingRepo {}
291
292 #[async_trait::async_trait]
293 impl BuildingRepository for BuildingRepo {
294 async fn create(&self, building: &Building) -> Result<Building, String>;
295 async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
296 async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
297 async fn find_all(&self) -> Result<Vec<Building>, String>;
298 async fn find_all_paginated(
299 &self,
300 page_request: &crate::application::dto::PageRequest,
301 filters: &crate::application::dto::BuildingFilters,
302 ) -> Result<(Vec<Building>, i64), String>;
303 async fn update(&self, building: &Building) -> Result<Building, String>;
304 async fn delete(&self, id: Uuid) -> Result<bool, String>;
305 async fn find_by_id_with_metrics(
306 &self,
307 id: Uuid,
308 ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
309 }
310 }
311
312 mock! {
314 pub AcpRepo {}
315
316 #[async_trait::async_trait]
317 impl AcpRepository for AcpRepo {
318 async fn create(&self, acp: &crate::domain::entities::Acp) -> Result<crate::domain::entities::Acp, crate::application::error::AppError>;
319 async fn find_by_id(&self, id: Uuid) -> Result<Option<crate::domain::entities::Acp>, crate::application::error::AppError>;
320 async fn find_by_id_with_metrics(&self, id: Uuid) -> Result<Option<(crate::domain::entities::Acp, crate::domain::entities::AcpMetrics)>, crate::application::error::AppError>;
321 async fn list(&self, scope: crate::application::ports::ListScope) -> Result<Vec<crate::domain::entities::Acp>, crate::application::error::AppError>;
322 async fn list_with_metrics(
323 &self,
324 scope: crate::application::ports::ListScope,
325 ) -> Result<
326 Vec<(
327 crate::domain::entities::Acp,
328 crate::domain::entities::AcpMetrics,
329 )>,
330 crate::application::error::AppError,
331 >;
332 async fn update(&self, acp: &crate::domain::entities::Acp) -> Result<crate::domain::entities::Acp, crate::application::error::AppError>;
333 async fn archive(&self, id: Uuid) -> Result<(), crate::application::error::AppError>;
334 async fn count_buildings(&self, id: Uuid) -> Result<i64, crate::application::error::AppError>;
335 }
336 }
337
338 fn create_test_building(total_units: i32) -> Building {
339 Building::new(
340 Uuid::new_v4(),
341 "Test Building".to_string(),
342 "123 Test St".to_string(),
343 "Brussels".to_string(),
344 "1000".to_string(),
345 "Belgium".to_string(),
346 total_units,
347 1000,
348 Some(2020),
349 )
350 .unwrap()
351 }
352
353 #[tokio::test]
354 async fn test_elect_board_member_success() {
355 let mut mock_board_repo = MockBoardMemberRepo::new();
357 let mut mock_building_repo = MockBuildingRepo::new();
358
359 let building = create_test_building(25); let building_id = building.id;
361
362 mock_building_repo
363 .expect_find_by_id()
364 .with(eq(building_id))
365 .times(1)
366 .returning(move |_| Ok(Some(create_test_building(25))));
367
368 mock_board_repo
369 .expect_create()
370 .times(1)
371 .returning(|member| Ok(member.clone()));
372
373 let use_cases = BoardMemberUseCases::new(
374 Arc::new(mock_board_repo),
375 Arc::new(mock_building_repo),
376 Arc::new(MockAcpRepo::new()),
377 );
378
379 let dto = CreateBoardMemberDto {
380 owner_id: Uuid::new_v4().to_string(),
381 building_id: building_id.to_string(),
382 position: "president".to_string(),
383 mandate_start: Utc::now().to_rfc3339(),
384 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
385 elected_by_meeting_id: Uuid::new_v4().to_string(),
386 };
387
388 let result = use_cases.elect_board_member(dto).await;
390
391 assert!(result.is_ok());
393 let response = result.unwrap();
394 assert_eq!(response.position, "president");
395 assert!(response.is_active);
396 }
397
398 #[tokio::test]
399 async fn test_elect_board_member_fails_building_not_found() {
400 let mock_board_repo = MockBoardMemberRepo::new();
402 let mut mock_building_repo = MockBuildingRepo::new();
403
404 let building_id = Uuid::new_v4();
405
406 mock_building_repo
407 .expect_find_by_id()
408 .with(eq(building_id))
409 .times(1)
410 .returning(|_| Ok(None)); let use_cases = BoardMemberUseCases::new(
413 Arc::new(mock_board_repo),
414 Arc::new(mock_building_repo),
415 Arc::new(MockAcpRepo::new()),
416 );
417
418 let dto = CreateBoardMemberDto {
419 owner_id: Uuid::new_v4().to_string(),
420 building_id: building_id.to_string(),
421 position: "president".to_string(),
422 mandate_start: Utc::now().to_rfc3339(),
423 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
424 elected_by_meeting_id: Uuid::new_v4().to_string(),
425 };
426
427 let result = use_cases.elect_board_member(dto).await;
429
430 assert!(result.is_err());
432 assert_eq!(result.unwrap_err(), "Building not found");
433 }
434
435 #[tokio::test]
436 async fn test_elect_board_member_fails_building_too_small() {
437 let mock_board_repo = MockBoardMemberRepo::new();
439 let mut mock_building_repo = MockBuildingRepo::new();
440
441 let building = create_test_building(15); let building_id = building.id;
443
444 mock_building_repo
445 .expect_find_by_id()
446 .with(eq(building_id))
447 .times(1)
448 .returning(move |_| Ok(Some(create_test_building(15))));
449
450 let use_cases = BoardMemberUseCases::new(
451 Arc::new(mock_board_repo),
452 Arc::new(mock_building_repo),
453 Arc::new(MockAcpRepo::new()),
454 );
455
456 let dto = CreateBoardMemberDto {
457 owner_id: Uuid::new_v4().to_string(),
458 building_id: building_id.to_string(),
459 position: "president".to_string(),
460 mandate_start: Utc::now().to_rfc3339(),
461 mandate_end: (Utc::now() + Duration::days(365)).to_rfc3339(),
462 elected_by_meeting_id: Uuid::new_v4().to_string(),
463 };
464
465 let result = use_cases.elect_board_member(dto).await;
467
468 assert!(result.is_err());
470 assert_eq!(
471 result.unwrap_err(),
472 "Board of directors is only required for buildings with more than 20 units"
473 );
474 }
475
476 #[tokio::test]
477 async fn test_get_board_stats() {
478 let mut mock_board_repo = MockBoardMemberRepo::new();
480 let mock_building_repo = MockBuildingRepo::new();
481
482 let building_id = Uuid::new_v4();
483 let user_id = Uuid::new_v4();
484
485 let president = BoardMember::new(
487 user_id,
488 building_id,
489 BoardPosition::President,
490 Utc::now() - Duration::days(100),
491 Utc::now() + Duration::days(265),
492 Uuid::new_v4(),
493 )
494 .unwrap();
495
496 let treasurer = BoardMember::new(
497 Uuid::new_v4(),
498 building_id,
499 BoardPosition::Treasurer,
500 Utc::now() - Duration::days(320),
501 Utc::now() + Duration::days(45), Uuid::new_v4(),
503 )
504 .unwrap();
505
506 let all_members = vec![president.clone(), treasurer.clone()];
507 let active_members = vec![president.clone(), treasurer.clone()];
508 let expiring = vec![treasurer.clone()];
509
510 mock_board_repo
511 .expect_find_by_building()
512 .with(eq(building_id))
513 .times(1)
514 .return_once(move |_| Ok(all_members));
515
516 mock_board_repo
517 .expect_find_active_by_building()
518 .with(eq(building_id))
519 .times(1)
520 .return_once(move |_| Ok(active_members));
521
522 mock_board_repo
523 .expect_find_expiring_soon()
524 .with(eq(building_id), eq(60))
525 .times(1)
526 .return_once(move |_, _| Ok(expiring));
527
528 let use_cases = BoardMemberUseCases::new(
529 Arc::new(mock_board_repo),
530 Arc::new(mock_building_repo),
531 Arc::new(MockAcpRepo::new()),
532 );
533
534 let result = use_cases.get_board_stats(building_id).await;
536
537 assert!(result.is_ok());
539 let stats = result.unwrap();
540 assert_eq!(stats.total_members, 2);
541 assert_eq!(stats.active_members, 2);
542 assert_eq!(stats.expiring_soon, 1);
543 assert!(stats.has_president);
544 assert!(stats.has_treasurer);
545 }
546
547 #[tokio::test]
548 async fn test_renew_mandate_success() {
549 let mut mock_board_repo = MockBoardMemberRepo::new();
551 let mock_building_repo = MockBuildingRepo::new();
552
553 let member_id = Uuid::new_v4();
554 let building_id = Uuid::new_v4();
555
556 let member = BoardMember::new(
558 Uuid::new_v4(),
559 building_id,
560 BoardPosition::President,
561 Utc::now() - Duration::days(320),
562 Utc::now() + Duration::days(45), Uuid::new_v4(),
564 )
565 .unwrap();
566
567 let member_clone = member.clone();
568
569 mock_board_repo
570 .expect_find_by_id()
571 .with(eq(member_id))
572 .times(1)
573 .return_once(move |_| Ok(Some(member_clone)));
574
575 mock_board_repo
576 .expect_update()
577 .times(1)
578 .returning(|m| Ok(m.clone()));
579
580 let use_cases = BoardMemberUseCases::new(
581 Arc::new(mock_board_repo),
582 Arc::new(mock_building_repo),
583 Arc::new(MockAcpRepo::new()),
584 );
585
586 let dto = RenewMandateDto {
587 new_elected_by_meeting_id: Uuid::new_v4().to_string(),
588 mandate_duration_days: 365, };
590
591 let result = use_cases.renew_mandate(member_id, dto).await;
593
594 assert!(result.is_ok());
596 let response = result.unwrap();
597 assert!(response.days_remaining > 300); }
599}