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 serde::Serialize;
8use std::sync::Arc;
9use uuid::Uuid;
10
11/// Active board mandate enriched with building info (for the "my-mandates" endpoint).
12#[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    /// Élit un nouveau membre du conseil de copropriété
42    /// Vérifie que l'immeuble a plus de 20 lots (obligation légale)
43    pub async fn elect_board_member(
44        &self,
45        dto: CreateBoardMemberDto,
46    ) -> Result<BoardMemberResponseDto, String> {
47        // Parse UUIDs
48        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        // Vérifier que l'immeuble existe et a plus de 20 lots
56        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        // Parse position
70        let position: BoardPosition = dto
71            .position
72            .parse()
73            .map_err(|e| format!("Invalid position: {}", e))?;
74
75        // Parse dates
76        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        // Créer l'entité domain avec validation
85        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        // Persister
95        let created = self.repository.create(&board_member).await?;
96
97        Ok(self.to_response_dto(&created))
98    }
99
100    /// Obtient un membre du conseil par son ID
101    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    /// Liste tous les membres actifs du conseil pour un immeuble
110    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    /// Liste tous les membres du conseil (incluant historique) pour un immeuble
119    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    /// Renouvelle le mandat d'un membre du conseil
128    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        // Renouveler le mandat (validation dans l'entity)
143        member.extend_mandate(dto.mandate_duration_days, new_meeting_id)?;
144
145        // Persister
146        let updated = self.repository.update(&member).await?;
147
148        Ok(self.to_response_dto(&updated))
149    }
150
151    /// Démissionne un membre du conseil (suppression)
152    pub async fn remove_board_member(&self, id: Uuid) -> Result<bool, String> {
153        self.repository.delete(id).await
154    }
155
156    /// Obtient les statistiques du conseil pour un immeuble
157    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    /// Get all active mandates for a given owner in a given organization, enriched with building info.
180    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    /// Vérifie si un propriétaire a un mandat actif pour un immeuble
221    /// Utilisé pour l'autorisation d'accès au tableau de bord
222    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    /// Mapper entity → DTO response
233    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 du BoardMemberRepository
260    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 du BuildingRepository
280    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        // Arrange
317        let mut mock_board_repo = MockBoardMemberRepo::new();
318        let mut mock_building_repo = MockBuildingRepo::new();
319
320        let building = create_test_building(25); // >20 lots
321        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        // Act
347        let result = use_cases.elect_board_member(dto).await;
348
349        // Assert
350        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        // Arrange
359        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)); // Building not found
369
370        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        // Act
383        let result = use_cases.elect_board_member(dto).await;
384
385        // Assert
386        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        // Arrange
393        let mock_board_repo = MockBoardMemberRepo::new();
394        let mut mock_building_repo = MockBuildingRepo::new();
395
396        let building = create_test_building(15); // ≤20 lots
397        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        // Act
418        let result = use_cases.elect_board_member(dto).await;
419
420        // Assert
421        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        // Arrange
431        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        // Créer des membres test
438        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), // Expire soon
454            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        // Act
484        let result = use_cases.get_board_stats(building_id).await;
485
486        // Assert
487        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        // Arrange
499        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        // Membre avec mandat qui expire bientôt
506        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), // Expire dans 45 jours
512            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        // Act
538        let result = use_cases.renew_mandate(member_id, dto).await;
539
540        // Assert
541        assert!(result.is_ok());
542        let response = result.unwrap();
543        assert!(response.days_remaining > 300); // Nouveau mandat d'un an
544    }
545}