koprogo_api/application/use_cases/
board_member_use_cases.rs

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    /// Élit un nouveau membre du conseil de copropriété
27    /// Vérifie que l'immeuble a plus de 20 lots (obligation légale)
28    pub async fn elect_board_member(
29        &self,
30        dto: CreateBoardMemberDto,
31    ) -> Result<BoardMemberResponseDto, String> {
32        // Parse UUIDs
33        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        // Vérifier que l'immeuble existe et a plus de 20 lots
41        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        // Parse position
55        let position: BoardPosition = dto
56            .position
57            .parse()
58            .map_err(|e| format!("Invalid position: {}", e))?;
59
60        // Parse dates
61        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        // Créer l'entité domain avec validation
70        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        // Persister
80        let created = self.repository.create(&board_member).await?;
81
82        Ok(self.to_response_dto(&created))
83    }
84
85    /// Obtient un membre du conseil par son ID
86    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    /// Liste tous les membres actifs du conseil pour un immeuble
95    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    /// Liste tous les membres du conseil (incluant historique) pour un immeuble
104    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    /// Renouvelle le mandat d'un membre du conseil
113    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        // Renouveler le mandat (validation dans l'entity)
128        member.extend_mandate(new_meeting_id)?;
129
130        // Persister
131        let updated = self.repository.update(&member).await?;
132
133        Ok(self.to_response_dto(&updated))
134    }
135
136    /// Démissionne un membre du conseil (suppression)
137    pub async fn remove_board_member(&self, id: Uuid) -> Result<bool, String> {
138        self.repository.delete(id).await
139    }
140
141    /// Obtient les statistiques du conseil pour un immeuble
142    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    /// Vérifie si un propriétaire a un mandat actif pour un immeuble
165    /// Utilisé pour l'autorisation d'accès au tableau de bord
166    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    /// Mapper entity → DTO response
177    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 du BoardMemberRepository
204    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 du BuildingRepository
224    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        // Arrange
261        let mut mock_board_repo = MockBoardMemberRepo::new();
262        let mut mock_building_repo = MockBuildingRepo::new();
263
264        let building = create_test_building(25); // >20 lots
265        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        // Act
291        let result = use_cases.elect_board_member(dto).await;
292
293        // Assert
294        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        // Arrange
303        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)); // Building not found
313
314        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        // Act
327        let result = use_cases.elect_board_member(dto).await;
328
329        // Assert
330        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        // Arrange
337        let mock_board_repo = MockBoardMemberRepo::new();
338        let mut mock_building_repo = MockBuildingRepo::new();
339
340        let building = create_test_building(15); // ≤20 lots
341        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        // Act
362        let result = use_cases.elect_board_member(dto).await;
363
364        // Assert
365        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        // Arrange
375        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        // Créer des membres test
382        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), // Expire soon
398            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        // Act
428        let result = use_cases.get_board_stats(building_id).await;
429
430        // Assert
431        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        // Arrange
443        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        // Membre avec mandat qui expire bientôt
450        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), // Expire dans 45 jours
456            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        // Act
481        let result = use_cases.renew_mandate(member_id, dto).await;
482
483        // Assert
484        assert!(result.is_ok());
485        let response = result.unwrap();
486        assert!(response.days_remaining > 300); // Nouveau mandat d'un an
487    }
488}