Skip to main content

koprogo_api/application/use_cases/
board_member_use_cases.rs

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/// 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    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    /// Élit un nouveau membre du conseil de copropriété
45    /// Vérifie que l'immeuble a plus de 20 lots (obligation légale)
46    pub async fn elect_board_member(
47        &self,
48        dto: CreateBoardMemberDto,
49    ) -> Result<BoardMemberResponseDto, String> {
50        // Parse UUIDs
51        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        // Vérifier que l'immeuble existe et a plus de 20 lots
59        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        // Parse position
73        let position: BoardPosition = dto
74            .position
75            .parse()
76            .map_err(|e| format!("Invalid position: {}", e))?;
77
78        // Parse dates
79        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        // Créer l'entité domain avec validation
88        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        // Persister
98        let created = self.repository.create(&board_member).await?;
99
100        Ok(self.to_response_dto(&created))
101    }
102
103    /// Obtient un membre du conseil par son ID
104    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    /// Liste tous les membres actifs du conseil pour un immeuble
113    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    /// Liste tous les membres du conseil (incluant historique) pour un immeuble
122    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    /// Renouvelle le mandat d'un membre du conseil
131    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        // Renouveler le mandat (validation dans l'entity)
146        member.extend_mandate(dto.mandate_duration_days, new_meeting_id)?;
147
148        // Persister
149        let updated = self.repository.update(&member).await?;
150
151        Ok(self.to_response_dto(&updated))
152    }
153
154    /// Démissionne un membre du conseil (suppression)
155    pub async fn remove_board_member(&self, id: Uuid) -> Result<bool, String> {
156        self.repository.delete(id).await
157    }
158
159    /// Obtient les statistiques du conseil pour un immeuble
160    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    /// Get all active mandates for a given owner in a given organization, enriched with building info.
183    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            // Hotfix #603 — filtre org via ACP→organization resolution.
205            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    /// Vérifie si un propriétaire a un mandat actif pour un immeuble
230    /// Utilisé pour l'autorisation d'accès au tableau de bord
231    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    /// Mapper entity → DTO response
242    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 du BoardMemberRepository
269    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 du BuildingRepository
289    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 du AcpRepository (Hotfix #603 — pour BoardMemberUseCases::new)
313    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        // Arrange
356        let mut mock_board_repo = MockBoardMemberRepo::new();
357        let mut mock_building_repo = MockBuildingRepo::new();
358
359        let building = create_test_building(25); // >20 lots
360        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        // Act
389        let result = use_cases.elect_board_member(dto).await;
390
391        // Assert
392        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        // Arrange
401        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)); // Building not found
411
412        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        // Act
428        let result = use_cases.elect_board_member(dto).await;
429
430        // Assert
431        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        // Arrange
438        let mock_board_repo = MockBoardMemberRepo::new();
439        let mut mock_building_repo = MockBuildingRepo::new();
440
441        let building = create_test_building(15); // ≤20 lots
442        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        // Act
466        let result = use_cases.elect_board_member(dto).await;
467
468        // Assert
469        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        // Arrange
479        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        // Créer des membres test
486        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), // Expire soon
502            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        // Act
535        let result = use_cases.get_board_stats(building_id).await;
536
537        // Assert
538        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        // Arrange
550        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        // Membre avec mandat qui expire bientôt
557        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), // Expire dans 45 jours
563            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, // ≈ 1 an (Art. 3.90 CC, conseil)
589        };
590
591        // Act
592        let result = use_cases.renew_mandate(member_id, dto).await;
593
594        // Assert
595        assert!(result.is_ok());
596        let response = result.unwrap();
597        assert!(response.days_remaining > 300); // Nouveau mandat d'un an
598    }
599}