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        }
309    }
310
311    // Mock du repository Meeting
312    mock! {
313        pub MeetingRepo {}
314
315        #[async_trait::async_trait]
316        impl MeetingRepository for MeetingRepo {
317            async fn create(&self, meeting: &Meeting) -> Result<Meeting, String>;
318            async fn find_by_id(&self, id: Uuid) -> Result<Option<Meeting>, String>;
319            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Meeting>, String>;
320            async fn find_all_paginated(
321                &self,
322                page_request: &crate::application::dto::PageRequest,
323                organization_id: Option<Uuid>,
324            ) -> Result<(Vec<Meeting>, i64), String>;
325            async fn update(&self, meeting: &Meeting) -> Result<Meeting, String>;
326            async fn delete(&self, id: Uuid) -> Result<bool, String>;
327        }
328    }
329
330    #[tokio::test]
331    async fn test_create_decision_success() {
332        let org_id = Uuid::new_v4();
333        let building_id = Uuid::new_v4();
334        let meeting_id = Uuid::new_v4();
335
336        let mut decision_repo = MockDecisionRepository::new();
337        let mut building_repo = MockBuildingRepo::new();
338        let mut meeting_repo = MockMeetingRepo::new();
339
340        // Mock building exists
341        let building = Building::new(
342            org_id,
343            "Test Building".to_string(),
344            "123 Main St".to_string(),
345            "Brussels".to_string(),
346            "1000".to_string(),
347            "Belgium".to_string(),
348            25,
349            1000,
350            Some(2020),
351        )
352        .unwrap();
353        building_repo
354            .expect_find_by_id()
355            .with(eq(building_id))
356            .times(1)
357            .returning(move |_| Ok(Some(building.clone())));
358
359        // Mock meeting exists
360        use crate::domain::entities::MeetingType;
361        let meeting = Meeting::new(
362            org_id,
363            building_id,
364            MeetingType::Ordinary,
365            "Test AG".to_string(),
366            None,
367            Utc::now(),
368            "Test Location".to_string(),
369        )
370        .unwrap();
371        meeting_repo
372            .expect_find_by_id()
373            .with(eq(meeting_id))
374            .times(1)
375            .returning(move |_| Ok(Some(meeting.clone())));
376
377        // Mock create decision
378        decision_repo
379            .expect_create()
380            .times(1)
381            .returning(|decision| Ok(decision.clone()));
382
383        let use_cases = BoardDecisionUseCases::new(
384            Arc::new(decision_repo),
385            Arc::new(building_repo),
386            Arc::new(meeting_repo),
387        );
388
389        let dto = CreateBoardDecisionDto {
390            building_id: building_id.to_string(),
391            meeting_id: meeting_id.to_string(),
392            subject: "Travaux urgents".to_string(),
393            decision_text: "Effectuer les travaux de toiture".to_string(),
394            deadline: Some((Utc::now() + chrono::Duration::days(30)).to_rfc3339()),
395        };
396
397        let result = use_cases.create_decision(dto).await;
398        assert!(result.is_ok());
399        let response = result.unwrap();
400        assert_eq!(response.subject, "Travaux urgents");
401        assert_eq!(response.status, "pending");
402    }
403
404    #[tokio::test]
405    async fn test_create_decision_fails_building_not_found() {
406        let building_id = Uuid::new_v4();
407        let meeting_id = Uuid::new_v4();
408
409        let decision_repo = MockDecisionRepository::new();
410        let mut building_repo = MockBuildingRepo::new();
411        let meeting_repo = MockMeetingRepo::new();
412
413        // Mock building not found
414        building_repo
415            .expect_find_by_id()
416            .with(eq(building_id))
417            .times(1)
418            .returning(|_| Ok(None));
419
420        let use_cases = BoardDecisionUseCases::new(
421            Arc::new(decision_repo),
422            Arc::new(building_repo),
423            Arc::new(meeting_repo),
424        );
425
426        let dto = CreateBoardDecisionDto {
427            building_id: building_id.to_string(),
428            meeting_id: meeting_id.to_string(),
429            subject: "Test".to_string(),
430            decision_text: "Test".to_string(),
431            deadline: None,
432        };
433
434        let result = use_cases.create_decision(dto).await;
435        assert!(result.is_err());
436        assert_eq!(result.unwrap_err(), "Building not found");
437    }
438
439    #[tokio::test]
440    async fn test_create_decision_fails_meeting_not_found() {
441        let org_id = Uuid::new_v4();
442        let building_id = Uuid::new_v4();
443        let meeting_id = Uuid::new_v4();
444
445        let decision_repo = MockDecisionRepository::new();
446        let mut building_repo = MockBuildingRepo::new();
447        let mut meeting_repo = MockMeetingRepo::new();
448
449        // Mock building exists
450        let building = Building::new(
451            org_id,
452            "Test Building".to_string(),
453            "123 Main St".to_string(),
454            "Brussels".to_string(),
455            "1000".to_string(),
456            "Belgium".to_string(),
457            25,
458            1000,
459            Some(2020),
460        )
461        .unwrap();
462        building_repo
463            .expect_find_by_id()
464            .with(eq(building_id))
465            .times(1)
466            .returning(move |_| Ok(Some(building.clone())));
467
468        // Mock meeting not found
469        meeting_repo
470            .expect_find_by_id()
471            .with(eq(meeting_id))
472            .times(1)
473            .returning(|_| Ok(None));
474
475        let use_cases = BoardDecisionUseCases::new(
476            Arc::new(decision_repo),
477            Arc::new(building_repo),
478            Arc::new(meeting_repo),
479        );
480
481        let dto = CreateBoardDecisionDto {
482            building_id: building_id.to_string(),
483            meeting_id: meeting_id.to_string(),
484            subject: "Test".to_string(),
485            decision_text: "Test".to_string(),
486            deadline: None,
487        };
488
489        let result = use_cases.create_decision(dto).await;
490        assert!(result.is_err());
491        assert_eq!(result.unwrap_err(), "Meeting not found");
492    }
493
494    #[tokio::test]
495    async fn test_get_decision_stats() {
496        let building_id = Uuid::new_v4();
497
498        let mut decision_repo = MockDecisionRepository::new();
499        let building_repo = MockBuildingRepo::new();
500        let meeting_repo = MockMeetingRepo::new();
501
502        // Mock count_by_status for each status
503        decision_repo
504            .expect_count_by_status()
505            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Pending)
506            .times(1)
507            .returning(|_, _| Ok(3));
508
509        decision_repo
510            .expect_count_by_status()
511            .withf(move |id, status| *id == building_id && *status == DecisionStatus::InProgress)
512            .times(1)
513            .returning(|_, _| Ok(2));
514
515        decision_repo
516            .expect_count_by_status()
517            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Completed)
518            .times(1)
519            .returning(|_, _| Ok(4));
520
521        decision_repo
522            .expect_count_by_status()
523            .withf(move |id, status| *id == building_id && *status == DecisionStatus::Cancelled)
524            .times(1)
525            .returning(|_, _| Ok(1));
526
527        // Mock count_overdue
528        decision_repo
529            .expect_count_overdue()
530            .with(eq(building_id))
531            .times(1)
532            .returning(|_| Ok(2));
533
534        let use_cases = BoardDecisionUseCases::new(
535            Arc::new(decision_repo),
536            Arc::new(building_repo),
537            Arc::new(meeting_repo),
538        );
539
540        let result = use_cases.get_decision_stats(building_id).await;
541        assert!(result.is_ok());
542        let stats = result.unwrap();
543        assert_eq!(stats.total_decisions, 12); // 3 + 2 + 4 + 2 + 1
544        assert_eq!(stats.pending, 3);
545        assert_eq!(stats.in_progress, 2);
546        assert_eq!(stats.completed, 4);
547        assert_eq!(stats.overdue, 2);
548        assert_eq!(stats.cancelled, 1);
549    }
550
551    #[tokio::test]
552    async fn test_update_decision_status() {
553        let decision_id = Uuid::new_v4();
554        let building_id = Uuid::new_v4();
555        let meeting_id = Uuid::new_v4();
556
557        let mut decision_repo = MockDecisionRepository::new();
558        let building_repo = MockBuildingRepo::new();
559        let meeting_repo = MockMeetingRepo::new();
560
561        let decision = BoardDecision::new(
562            building_id,
563            meeting_id,
564            "Test".to_string(),
565            "Test decision".to_string(),
566            None,
567        )
568        .unwrap();
569
570        // Mock find_by_id
571        decision_repo
572            .expect_find_by_id()
573            .with(eq(decision_id))
574            .times(1)
575            .returning(move |_| Ok(Some(decision.clone())));
576
577        // Mock update
578        decision_repo
579            .expect_update()
580            .times(1)
581            .returning(|decision| Ok(decision.clone()));
582
583        let use_cases = BoardDecisionUseCases::new(
584            Arc::new(decision_repo),
585            Arc::new(building_repo),
586            Arc::new(meeting_repo),
587        );
588
589        let dto = UpdateBoardDecisionDto {
590            status: "in_progress".to_string(),
591            notes: Some("Work in progress".to_string()),
592        };
593
594        let result = use_cases.update_decision_status(decision_id, dto).await;
595        assert!(result.is_ok());
596        let response = result.unwrap();
597        assert_eq!(response.status, "in_progress");
598        assert!(response.notes.is_some());
599    }
600}