Skip to main content

koprogo_api/application/use_cases/
board_decision_use_cases.rs

1use crate::application::dto::{
2    AddDecisionNotesDto, BoardDecisionResponseDto, CreateBoardDecisionDto, DecisionStatsDto,
3    UpdateBoardDecisionDto,
4};
5use crate::application::ports::{BoardDecisionRepository, BuildingRepository, MeetingRepository};
6use crate::domain::entities::{BoardDecision, DecisionStatus};
7use chrono::{DateTime, Utc};
8use std::sync::Arc;
9use uuid::Uuid;
10
11/// Use cases pour la gestion des décisions du conseil de copropriété
12pub struct BoardDecisionUseCases {
13    decision_repository: Arc<dyn BoardDecisionRepository>,
14    building_repository: Arc<dyn BuildingRepository>,
15    meeting_repository: Arc<dyn MeetingRepository>,
16}
17
18impl BoardDecisionUseCases {
19    pub fn new(
20        decision_repository: Arc<dyn BoardDecisionRepository>,
21        building_repository: Arc<dyn BuildingRepository>,
22        meeting_repository: Arc<dyn MeetingRepository>,
23    ) -> Self {
24        Self {
25            decision_repository,
26            building_repository,
27            meeting_repository,
28        }
29    }
30
31    /// Crée une nouvelle décision à suivre suite à une AG
32    pub async fn create_decision(
33        &self,
34        dto: CreateBoardDecisionDto,
35    ) -> Result<BoardDecisionResponseDto, String> {
36        // Valider que l'immeuble existe
37        let building_id = Uuid::parse_str(&dto.building_id)
38            .map_err(|_| "Invalid building ID format".to_string())?;
39
40        self.building_repository
41            .find_by_id(building_id)
42            .await?
43            .ok_or_else(|| "Building not found".to_string())?;
44
45        // Valider que la réunion existe
46        let meeting_id = Uuid::parse_str(&dto.meeting_id)
47            .map_err(|_| "Invalid meeting ID format".to_string())?;
48
49        self.meeting_repository
50            .find_by_id(meeting_id)
51            .await?
52            .ok_or_else(|| "Meeting not found".to_string())?;
53
54        // Parser la deadline optionnelle
55        let deadline = if let Some(deadline_str) = &dto.deadline {
56            Some(
57                DateTime::parse_from_rfc3339(deadline_str)
58                    .map_err(|_| "Invalid deadline format".to_string())?
59                    .with_timezone(&Utc),
60            )
61        } else {
62            None
63        };
64
65        // Créer l'entité BoardDecision
66        let decision = BoardDecision::new(
67            building_id,
68            meeting_id,
69            dto.subject,
70            dto.decision_text,
71            deadline,
72        )?;
73
74        // Persister
75        let created = self.decision_repository.create(&decision).await?;
76
77        Ok(Self::to_response_dto(created))
78    }
79
80    /// Récupère une décision par ID
81    pub async fn get_decision(&self, id: Uuid) -> Result<BoardDecisionResponseDto, String> {
82        let decision = self
83            .decision_repository
84            .find_by_id(id)
85            .await?
86            .ok_or_else(|| "Decision not found".to_string())?;
87
88        Ok(Self::to_response_dto(decision))
89    }
90
91    /// Liste toutes les décisions d'un immeuble
92    pub async fn list_decisions_by_building(
93        &self,
94        building_id: Uuid,
95    ) -> Result<Vec<BoardDecisionResponseDto>, String> {
96        let decisions = self
97            .decision_repository
98            .find_by_building(building_id)
99            .await?;
100
101        Ok(decisions.into_iter().map(Self::to_response_dto).collect())
102    }
103
104    /// Liste les décisions avec un statut donné
105    pub async fn list_decisions_by_status(
106        &self,
107        building_id: Uuid,
108        status: &str,
109    ) -> Result<Vec<BoardDecisionResponseDto>, String> {
110        let status_enum = status
111            .parse::<DecisionStatus>()
112            .map_err(|e| e.to_string())?;
113
114        let decisions = self
115            .decision_repository
116            .find_by_status(building_id, status_enum)
117            .await?;
118
119        Ok(decisions.into_iter().map(Self::to_response_dto).collect())
120    }
121
122    /// Liste les décisions en retard
123    pub async fn list_overdue_decisions(
124        &self,
125        building_id: Uuid,
126    ) -> Result<Vec<BoardDecisionResponseDto>, String> {
127        let decisions = self.decision_repository.find_overdue(building_id).await?;
128
129        Ok(decisions.into_iter().map(Self::to_response_dto).collect())
130    }
131
132    /// Met à jour le statut d'une décision
133    pub async fn update_decision_status(
134        &self,
135        id: Uuid,
136        dto: UpdateBoardDecisionDto,
137    ) -> Result<BoardDecisionResponseDto, String> {
138        let mut decision = self
139            .decision_repository
140            .find_by_id(id)
141            .await?
142            .ok_or_else(|| "Decision not found".to_string())?;
143
144        // Parser et mettre à jour le statut
145        let new_status = dto
146            .status
147            .parse::<DecisionStatus>()
148            .map_err(|e| e.to_string())?;
149        decision.update_status(new_status)?;
150
151        // Mettre à jour les notes si fournies
152        if let Some(notes) = dto.notes {
153            decision.add_notes(notes);
154        }
155
156        // Vérifier si la décision est en retard
157        decision.check_and_update_overdue_status();
158
159        // Persister les changements
160        let updated = self.decision_repository.update(&decision).await?;
161
162        Ok(Self::to_response_dto(updated))
163    }
164
165    /// Ajoute des notes à une décision
166    pub async fn add_notes(
167        &self,
168        id: Uuid,
169        dto: AddDecisionNotesDto,
170    ) -> Result<BoardDecisionResponseDto, String> {
171        let mut decision = self
172            .decision_repository
173            .find_by_id(id)
174            .await?
175            .ok_or_else(|| "Decision not found".to_string())?;
176
177        decision.add_notes(dto.notes);
178
179        let updated = self.decision_repository.update(&decision).await?;
180
181        Ok(Self::to_response_dto(updated))
182    }
183
184    /// Marque une décision comme complétée
185    pub async fn complete_decision(&self, id: Uuid) -> Result<BoardDecisionResponseDto, String> {
186        let mut decision = self
187            .decision_repository
188            .find_by_id(id)
189            .await?
190            .ok_or_else(|| "Decision not found".to_string())?;
191
192        decision.update_status(DecisionStatus::Completed)?;
193
194        let updated = self.decision_repository.update(&decision).await?;
195
196        Ok(Self::to_response_dto(updated))
197    }
198
199    /// Obtient des statistiques sur les décisions d'un immeuble
200    pub async fn get_decision_stats(&self, building_id: Uuid) -> Result<DecisionStatsDto, String> {
201        let pending = self
202            .decision_repository
203            .count_by_status(building_id, DecisionStatus::Pending)
204            .await?;
205
206        let in_progress = self
207            .decision_repository
208            .count_by_status(building_id, DecisionStatus::InProgress)
209            .await?;
210
211        let completed = self
212            .decision_repository
213            .count_by_status(building_id, DecisionStatus::Completed)
214            .await?;
215
216        let overdue = self.decision_repository.count_overdue(building_id).await?;
217
218        let cancelled = self
219            .decision_repository
220            .count_by_status(building_id, DecisionStatus::Cancelled)
221            .await?;
222
223        let total = pending + in_progress + completed + overdue + cancelled;
224
225        Ok(DecisionStatsDto {
226            building_id: building_id.to_string(),
227            total_decisions: total,
228            pending,
229            in_progress,
230            completed,
231            overdue,
232            cancelled,
233        })
234    }
235
236    /// Convertit une entité BoardDecision en DTO de réponse
237    fn to_response_dto(decision: BoardDecision) -> BoardDecisionResponseDto {
238        let days_until_deadline = decision.deadline.map(|deadline| {
239            let now = Utc::now();
240            (deadline - now).num_days()
241        });
242
243        BoardDecisionResponseDto {
244            id: decision.id.to_string(),
245            building_id: decision.building_id.to_string(),
246            meeting_id: decision.meeting_id.to_string(),
247            subject: decision.subject.clone(),
248            decision_text: decision.decision_text.clone(),
249            deadline: decision.deadline.map(|d| d.to_rfc3339()),
250            status: decision.status.to_string(),
251            completed_at: decision.completed_at.map(|d| d.to_rfc3339()),
252            notes: decision.notes.clone(),
253            is_overdue: decision.is_overdue(),
254            days_until_deadline,
255            created_at: decision.created_at.to_rfc3339(),
256            updated_at: decision.updated_at.to_rfc3339(),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::application::ports::{
265        BoardDecisionRepository, BuildingRepository, MeetingRepository,
266    };
267    use crate::domain::entities::{Building, Meeting};
268    use mockall::mock;
269    use mockall::predicate::*;
270
271    // Mock du repository BoardDecision
272    mock! {
273        pub DecisionRepository {}
274
275        #[async_trait::async_trait]
276        impl BoardDecisionRepository for DecisionRepository {
277            async fn create(&self, decision: &BoardDecision) -> Result<BoardDecision, String>;
278            async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardDecision>, String>;
279            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardDecision>, String>;
280            async fn find_by_meeting(&self, meeting_id: Uuid) -> Result<Vec<BoardDecision>, String>;
281            async fn find_by_status(&self, building_id: Uuid, status: DecisionStatus) -> Result<Vec<BoardDecision>, String>;
282            async fn find_overdue(&self, building_id: Uuid) -> Result<Vec<BoardDecision>, String>;
283            async fn find_deadline_approaching(&self, building_id: Uuid, days_threshold: i32) -> Result<Vec<BoardDecision>, String>;
284            async fn update(&self, decision: &BoardDecision) -> Result<BoardDecision, String>;
285            async fn delete(&self, id: Uuid) -> Result<bool, String>;
286            async fn count_by_status(&self, building_id: Uuid, status: DecisionStatus) -> Result<i64, String>;
287            async fn count_overdue(&self, building_id: Uuid) -> Result<i64, String>;
288        }
289    }
290
291    // Mock du repository Building
292    mock! {
293        pub BuildingRepo {}
294
295        #[async_trait::async_trait]
296        impl BuildingRepository for BuildingRepo {
297            async fn create(&self, building: &Building) -> Result<Building, String>;
298            async fn find_by_id(&self, id: Uuid) -> Result<Option<Building>, String>;
299            async fn find_by_slug(&self, slug: &str) -> Result<Option<Building>, String>;
300            async fn find_all(&self) -> Result<Vec<Building>, String>;
301            async fn find_all_paginated(
302                &self,
303                page_request: &crate::application::dto::PageRequest,
304                filters: &crate::application::dto::BuildingFilters,
305            ) -> Result<(Vec<Building>, i64), String>;
306            async fn update(&self, building: &Building) -> Result<Building, String>;
307            async fn delete(&self, id: Uuid) -> Result<bool, String>;
308            async fn find_by_id_with_metrics(
309                &self,
310                id: Uuid,
311            ) -> Result<Option<(Building, crate::domain::entities::BuildingMetrics)>, String>;
312        }
313    }
314
315    // Mock du repository Meeting
316    mock! {
317        pub MeetingRepo {}
318
319        #[async_trait::async_trait]
320        impl MeetingRepository for MeetingRepo {
321            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
322            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
323            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
324            async fn find_all_paginated(
325                &self,
326                page_request: &crate::application::dto::PageRequest,
327                organization_id: Option<Uuid>,
328            ) -> Result<(Vec<Meeting>, i64), String>;
329            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
330            async fn delete(&self, id: Uuid) -> Result<bool, String>;
331        }
332    }
333
334    #[tokio::test]
335    async fn test_create_decision_success() {
336        let org_id = Uuid::new_v4();
337        let building_id = Uuid::new_v4();
338        let meeting_id = Uuid::new_v4();
339
340        let mut decision_repo = MockDecisionRepository::new();
341        let mut building_repo = MockBuildingRepo::new();
342        let mut meeting_repo = MockMeetingRepo::new();
343
344        // Mock building exists
345        let building = Building::new(
346            org_id,
347            "Test Building".to_string(),
348            "123 Main St".to_string(),
349            "Brussels".to_string(),
350            "1000".to_string(),
351            "Belgium".to_string(),
352            25,
353            1000,
354            Some(2020),
355        )
356        .unwrap();
357        building_repo
358            .expect_find_by_id()
359            .with(eq(building_id))
360            .times(1)
361            .returning(move |_| Ok(Some(building.clone())));
362
363        // Mock meeting exists
364        use crate::domain::entities::MeetingType;
365        let meeting = Meeting::new(
366            Uuid::new_v4(), // acp_id
367            org_id,
368            building_id,
369            MeetingType::Ordinary,
370            "Test AG".to_string(),
371            None,
372            Utc::now(),
373            "Test Location".to_string(),
374        )
375        .unwrap();
376        meeting_repo
377            .expect_find_by_id()
378            .with(eq(meeting_id))
379            .times(1)
380            .returning(move |_| Ok(Some(meeting.clone())));
381
382        // Mock create decision
383        decision_repo
384            .expect_create()
385            .times(1)
386            .returning(|decision| Ok(decision.clone()));
387
388        let use_cases = BoardDecisionUseCases::new(
389            Arc::new(decision_repo),
390            Arc::new(building_repo),
391            Arc::new(meeting_repo),
392        );
393
394        let dto = CreateBoardDecisionDto {
395            building_id: building_id.to_string(),
396            meeting_id: meeting_id.to_string(),
397            subject: "Travaux urgents".to_string(),
398            decision_text: "Effectuer les travaux de toiture".to_string(),
399            deadline: Some((Utc::now() + chrono::Duration::days(30)).to_rfc3339()),
400        };
401
402        let result = use_cases.create_decision(dto).await;
403        assert!(result.is_ok());
404        let response = result.unwrap();
405        assert_eq!(response.subject, "Travaux urgents");
406        assert_eq!(response.status, "pending");
407    }
408
409    #[tokio::test]
410    async fn test_create_decision_fails_building_not_found() {
411        let building_id = Uuid::new_v4();
412        let meeting_id = Uuid::new_v4();
413
414        let decision_repo = MockDecisionRepository::new();
415        let mut building_repo = MockBuildingRepo::new();
416        let meeting_repo = MockMeetingRepo::new();
417
418        // Mock building not found
419        building_repo
420            .expect_find_by_id()
421            .with(eq(building_id))
422            .times(1)
423            .returning(|_| Ok(None));
424
425        let use_cases = BoardDecisionUseCases::new(
426            Arc::new(decision_repo),
427            Arc::new(building_repo),
428            Arc::new(meeting_repo),
429        );
430
431        let dto = CreateBoardDecisionDto {
432            building_id: building_id.to_string(),
433            meeting_id: meeting_id.to_string(),
434            subject: "Test".to_string(),
435            decision_text: "Test".to_string(),
436            deadline: None,
437        };
438
439        let result = use_cases.create_decision(dto).await;
440        assert!(result.is_err());
441        assert_eq!(result.unwrap_err(), "Building not found");
442    }
443
444    #[tokio::test]
445    async fn test_create_decision_fails_meeting_not_found() {
446        let org_id = Uuid::new_v4();
447        let building_id = Uuid::new_v4();
448        let meeting_id = Uuid::new_v4();
449
450        let decision_repo = MockDecisionRepository::new();
451        let mut building_repo = MockBuildingRepo::new();
452        let mut meeting_repo = MockMeetingRepo::new();
453
454        // Mock building exists
455        let building = Building::new(
456            org_id,
457            "Test Building".to_string(),
458            "123 Main St".to_string(),
459            "Brussels".to_string(),
460            "1000".to_string(),
461            "Belgium".to_string(),
462            25,
463            1000,
464            Some(2020),
465        )
466        .unwrap();
467        building_repo
468            .expect_find_by_id()
469            .with(eq(building_id))
470            .times(1)
471            .returning(move |_| Ok(Some(building.clone())));
472
473        // Mock meeting not found
474        meeting_repo
475            .expect_find_by_id()
476            .with(eq(meeting_id))
477            .times(1)
478            .returning(|_| Ok(None));
479
480        let use_cases = BoardDecisionUseCases::new(
481            Arc::new(decision_repo),
482            Arc::new(building_repo),
483            Arc::new(meeting_repo),
484        );
485
486        let dto = CreateBoardDecisionDto {
487            building_id: building_id.to_string(),
488            meeting_id: meeting_id.to_string(),
489            subject: "Test".to_string(),
490            decision_text: "Test".to_string(),
491            deadline: None,
492        };
493
494        let result = use_cases.create_decision(dto).await;
495        assert!(result.is_err());
496        assert_eq!(result.unwrap_err(), "Meeting not found");
497    }
498
499    #[tokio::test]
500    async fn test_get_decision_stats() {
501        let building_id = Uuid::new_v4();
502
503        let mut decision_repo = MockDecisionRepository::new();
504        let building_repo = MockBuildingRepo::new();
505        let meeting_repo = MockMeetingRepo::new();
506
507        // Mock count_by_status for each status
508        decision_repo
509            .expect_count_by_status()
510            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Pending)
511            .times(1)
512            .returning(|_, _| Ok(3));
513
514        decision_repo
515            .expect_count_by_status()
516            .withf(move |id, status| *id == building_id && *status == DecisionStatus::InProgress)
517            .times(1)
518            .returning(|_, _| Ok(2));
519
520        decision_repo
521            .expect_count_by_status()
522            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Completed)
523            .times(1)
524            .returning(|_, _| Ok(4));
525
526        decision_repo
527            .expect_count_by_status()
528            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Cancelled)
529            .times(1)
530            .returning(|_, _| Ok(1));
531
532        // Mock count_overdue
533        decision_repo
534            .expect_count_overdue()
535            .with(eq(building_id))
536            .times(1)
537            .returning(|_| Ok(2));
538
539        let use_cases = BoardDecisionUseCases::new(
540            Arc::new(decision_repo),
541            Arc::new(building_repo),
542            Arc::new(meeting_repo),
543        );
544
545        let result = use_cases.get_decision_stats(building_id).await;
546        assert!(result.is_ok());
547        let stats = result.unwrap();
548        assert_eq!(stats.total_decisions, 12); // 3 + 2 + 4 + 2 + 1
549        assert_eq!(stats.pending, 3);
550        assert_eq!(stats.in_progress, 2);
551        assert_eq!(stats.completed, 4);
552        assert_eq!(stats.overdue, 2);
553        assert_eq!(stats.cancelled, 1);
554    }
555
556    #[tokio::test]
557    async fn test_update_decision_status() {
558        let decision_id = Uuid::new_v4();
559        let building_id = Uuid::new_v4();
560        let meeting_id = Uuid::new_v4();
561
562        let mut decision_repo = MockDecisionRepository::new();
563        let building_repo = MockBuildingRepo::new();
564        let meeting_repo = MockMeetingRepo::new();
565
566        let decision = BoardDecision::new(
567            building_id,
568            meeting_id,
569            "Test".to_string(),
570            "Test decision".to_string(),
571            None,
572        )
573        .unwrap();
574
575        // Mock find_by_id
576        decision_repo
577            .expect_find_by_id()
578            .with(eq(decision_id))
579            .times(1)
580            .returning(move |_| Ok(Some(decision.clone())));
581
582        // Mock update
583        decision_repo
584            .expect_update()
585            .times(1)
586            .returning(|decision| Ok(decision.clone()));
587
588        let use_cases = BoardDecisionUseCases::new(
589            Arc::new(decision_repo),
590            Arc::new(building_repo),
591            Arc::new(meeting_repo),
592        );
593
594        let dto = UpdateBoardDecisionDto {
595            status: "in_progress".to_string(),
596            notes: Some("Work in progress".to_string()),
597        };
598
599        let result = use_cases.update_decision_status(decision_id, dto).await;
600        assert!(result.is_ok());
601        let response = result.unwrap();
602        assert_eq!(response.status, "in_progress");
603        assert!(response.notes.is_some());
604    }
605}