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