koprogo_api/application/use_cases/
board_dashboard_use_cases.rs

1use crate::application::dto::{
2    BoardDecisionResponseDto, BoardMemberResponseDto, DeadlineAlertDto, DecisionStatsDto,
3};
4use crate::application::ports::{
5    BoardDecisionRepository, BoardMemberRepository, BuildingRepository,
6};
7#[cfg(test)]
8use crate::domain::entities::BoardPosition;
9use crate::domain::entities::{BoardDecision, BoardMember, DecisionStatus};
10use chrono::Utc;
11use std::sync::Arc;
12use uuid::Uuid;
13
14/// Board Dashboard Use Cases
15/// Provides aggregated data and alerts for board members
16pub struct BoardDashboardUseCases {
17    board_member_repo: Arc<dyn BoardMemberRepository>,
18    board_decision_repo: Arc<dyn BoardDecisionRepository>,
19    building_repo: Arc<dyn BuildingRepository>,
20}
21
22/// Dashboard response containing all aggregated data
23#[derive(Debug, serde::Serialize, serde::Deserialize)]
24pub struct BoardDashboardResponse {
25    pub my_mandate: Option<BoardMemberResponseDto>,
26    pub decisions_stats: DecisionStatsDto,
27    pub overdue_decisions: Vec<BoardDecisionResponseDto>,
28    pub upcoming_deadlines: Vec<DeadlineAlertDto>,
29}
30
31impl BoardDashboardUseCases {
32    pub fn new(
33        board_member_repo: Arc<dyn BoardMemberRepository>,
34        board_decision_repo: Arc<dyn BoardDecisionRepository>,
35        building_repo: Arc<dyn BuildingRepository>,
36    ) -> Self {
37        Self {
38            board_member_repo,
39            board_decision_repo,
40            building_repo,
41        }
42    }
43
44    /// Get complete dashboard data for a board member
45    pub async fn get_dashboard(
46        &self,
47        building_id: Uuid,
48        owner_id: Uuid,
49    ) -> Result<BoardDashboardResponse, String> {
50        // Verify building exists
51        self.building_repo
52            .find_by_id(building_id)
53            .await?
54            .ok_or_else(|| "Building not found".to_string())?;
55
56        // Get board member's mandate (if any)
57        let my_mandate = self
58            .board_member_repo
59            .find_by_owner_and_building(owner_id, building_id)
60            .await?
61            .map(|bm| self.to_board_member_dto(&bm));
62
63        // Get all decisions for the building
64        let decisions = self
65            .board_decision_repo
66            .find_by_building(building_id)
67            .await?;
68
69        // Calculate statistics
70        let decisions_stats = self.calculate_decision_stats(&decisions, building_id);
71
72        // Get overdue decisions
73        let overdue_decisions = self.get_overdue_decisions(&decisions);
74
75        // Get upcoming deadlines
76        let upcoming_deadlines = self.get_upcoming_deadlines(&decisions);
77
78        Ok(BoardDashboardResponse {
79            my_mandate,
80            decisions_stats,
81            overdue_decisions,
82            upcoming_deadlines,
83        })
84    }
85
86    /// Calculate decision statistics
87    fn calculate_decision_stats(
88        &self,
89        decisions: &[BoardDecision],
90        building_id: Uuid,
91    ) -> DecisionStatsDto {
92        let pending = decisions
93            .iter()
94            .filter(|d| d.status == DecisionStatus::Pending)
95            .count() as i64;
96        let in_progress = decisions
97            .iter()
98            .filter(|d| d.status == DecisionStatus::InProgress)
99            .count() as i64;
100        let completed = decisions
101            .iter()
102            .filter(|d| d.status == DecisionStatus::Completed)
103            .count() as i64;
104        let overdue = decisions
105            .iter()
106            .filter(|d| d.status == DecisionStatus::Overdue)
107            .count() as i64;
108        let cancelled = decisions
109            .iter()
110            .filter(|d| d.status == DecisionStatus::Cancelled)
111            .count() as i64;
112
113        DecisionStatsDto {
114            building_id: building_id.to_string(),
115            total_decisions: decisions.len() as i64,
116            pending,
117            in_progress,
118            completed,
119            overdue,
120            cancelled,
121        }
122    }
123
124    /// Get overdue decisions
125    fn get_overdue_decisions(&self, decisions: &[BoardDecision]) -> Vec<BoardDecisionResponseDto> {
126        decisions
127            .iter()
128            .filter(|d| d.status == DecisionStatus::Overdue)
129            .map(|d| self.to_decision_dto(d))
130            .collect()
131    }
132
133    /// Get upcoming deadlines (within 30 days)
134    fn get_upcoming_deadlines(&self, decisions: &[BoardDecision]) -> Vec<DeadlineAlertDto> {
135        let now = Utc::now();
136        let thirty_days = chrono::Duration::days(30);
137
138        decisions
139            .iter()
140            .filter(|d| {
141                if let Some(deadline) = d.deadline {
142                    let diff = deadline - now;
143                    diff > chrono::Duration::zero() && diff <= thirty_days
144                } else {
145                    false
146                }
147            })
148            .map(|d| {
149                let days_remaining = (d.deadline.unwrap() - now).num_days();
150                let urgency = if days_remaining <= 7 {
151                    "critical"
152                } else if days_remaining <= 14 {
153                    "high"
154                } else {
155                    "medium"
156                };
157
158                DeadlineAlertDto {
159                    decision_id: d.id.to_string(),
160                    subject: d.subject.clone(),
161                    deadline: d.deadline.unwrap().to_rfc3339(),
162                    days_remaining,
163                    urgency: urgency.to_string(),
164                }
165            })
166            .collect()
167    }
168
169    /// Convert BoardMember to DTO
170    fn to_board_member_dto(&self, bm: &BoardMember) -> BoardMemberResponseDto {
171        let now = Utc::now();
172        let days_remaining = (bm.mandate_end - now).num_days();
173        let expires_soon = days_remaining <= 60 && days_remaining > 0;
174        let is_active = now >= bm.mandate_start && now <= bm.mandate_end;
175
176        BoardMemberResponseDto {
177            id: bm.id.to_string(),
178            owner_id: bm.owner_id.to_string(),
179            building_id: bm.building_id.to_string(),
180            position: bm.position.to_string(),
181            mandate_start: bm.mandate_start.to_rfc3339(),
182            mandate_end: bm.mandate_end.to_rfc3339(),
183            elected_by_meeting_id: bm.elected_by_meeting_id.to_string(),
184            is_active,
185            days_remaining,
186            expires_soon,
187            created_at: bm.created_at.to_rfc3339(),
188            updated_at: bm.updated_at.to_rfc3339(),
189        }
190    }
191
192    /// Convert BoardDecision to DTO
193    fn to_decision_dto(&self, d: &BoardDecision) -> BoardDecisionResponseDto {
194        let now = Utc::now();
195        let is_overdue = d
196            .deadline
197            .map(|deadline| deadline < now && d.status != DecisionStatus::Completed)
198            .unwrap_or(false);
199        let days_until_deadline = d.deadline.map(|deadline| (deadline - now).num_days());
200
201        BoardDecisionResponseDto {
202            id: d.id.to_string(),
203            building_id: d.building_id.to_string(),
204            meeting_id: d.meeting_id.to_string(),
205            subject: d.subject.clone(),
206            decision_text: d.decision_text.clone(),
207            deadline: d.deadline.map(|dt| dt.to_rfc3339()),
208            status: d.status.to_string(),
209            completed_at: d.completed_at.map(|dt| dt.to_rfc3339()),
210            notes: d.notes.clone(),
211            is_overdue,
212            days_until_deadline,
213            created_at: d.created_at.to_rfc3339(),
214            updated_at: d.updated_at.to_rfc3339(),
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::domain::entities::{BoardDecision, BoardMember};
223    use async_trait::async_trait;
224    use chrono::Utc;
225    use std::sync::Mutex;
226    use uuid::Uuid;
227
228    // Mock BoardMemberRepository
229    struct MockBoardMemberRepository {
230        members: Mutex<Vec<BoardMember>>,
231    }
232
233    impl MockBoardMemberRepository {
234        fn new() -> Self {
235            Self {
236                members: Mutex::new(vec![]),
237            }
238        }
239
240        fn add_member(&self, member: BoardMember) {
241            self.members.lock().unwrap().push(member);
242        }
243    }
244
245    #[async_trait]
246    impl BoardMemberRepository for MockBoardMemberRepository {
247        async fn create(&self, _member: &BoardMember) -> Result<BoardMember, String> {
248            unimplemented!()
249        }
250
251        async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardMember>, String> {
252            Ok(self
253                .members
254                .lock()
255                .unwrap()
256                .iter()
257                .find(|m| m.id == id)
258                .cloned())
259        }
260
261        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardMember>, String> {
262            Ok(self
263                .members
264                .lock()
265                .unwrap()
266                .iter()
267                .filter(|m| m.building_id == building_id)
268                .cloned()
269                .collect())
270        }
271
272        async fn find_by_owner_and_building(
273            &self,
274            owner_id: Uuid,
275            building_id: Uuid,
276        ) -> Result<Option<BoardMember>, String> {
277            Ok(self
278                .members
279                .lock()
280                .unwrap()
281                .iter()
282                .find(|m| m.owner_id == owner_id && m.building_id == building_id)
283                .cloned())
284        }
285
286        async fn find_active_by_building(
287            &self,
288            building_id: Uuid,
289        ) -> Result<Vec<BoardMember>, String> {
290            let now = Utc::now();
291            Ok(self
292                .members
293                .lock()
294                .unwrap()
295                .iter()
296                .filter(|m| m.building_id == building_id && m.mandate_end > now)
297                .cloned()
298                .collect())
299        }
300
301        async fn find_expiring_soon(
302            &self,
303            building_id: Uuid,
304            days_threshold: i32,
305        ) -> Result<Vec<BoardMember>, String> {
306            let now = Utc::now();
307            let threshold = now + chrono::Duration::days(days_threshold as i64);
308            Ok(self
309                .members
310                .lock()
311                .unwrap()
312                .iter()
313                .filter(|m| {
314                    m.building_id == building_id
315                        && m.mandate_end > now
316                        && m.mandate_end <= threshold
317                })
318                .cloned()
319                .collect())
320        }
321
322        async fn find_by_owner(&self, owner_id: Uuid) -> Result<Vec<BoardMember>, String> {
323            Ok(self
324                .members
325                .lock()
326                .unwrap()
327                .iter()
328                .filter(|m| m.owner_id == owner_id)
329                .cloned()
330                .collect())
331        }
332
333        async fn has_active_mandate(
334            &self,
335            owner_id: Uuid,
336            building_id: Uuid,
337        ) -> Result<bool, String> {
338            let now = Utc::now();
339            Ok(self.members.lock().unwrap().iter().any(|m| {
340                m.owner_id == owner_id && m.building_id == building_id && m.mandate_end > now
341            }))
342        }
343
344        async fn update(&self, _member: &BoardMember) -> Result<BoardMember, String> {
345            unimplemented!()
346        }
347
348        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
349            unimplemented!()
350        }
351
352        async fn count_active_by_building(&self, building_id: Uuid) -> Result<i64, String> {
353            let now = Utc::now();
354            Ok(self
355                .members
356                .lock()
357                .unwrap()
358                .iter()
359                .filter(|m| m.building_id == building_id && m.mandate_end > now)
360                .count() as i64)
361        }
362    }
363
364    // Mock BoardDecisionRepository
365    struct MockBoardDecisionRepository {
366        decisions: Mutex<Vec<BoardDecision>>,
367    }
368
369    impl MockBoardDecisionRepository {
370        fn new() -> Self {
371            Self {
372                decisions: Mutex::new(vec![]),
373            }
374        }
375
376        fn add_decision(&self, decision: BoardDecision) {
377            self.decisions.lock().unwrap().push(decision);
378        }
379    }
380
381    #[async_trait]
382    impl BoardDecisionRepository for MockBoardDecisionRepository {
383        async fn create(&self, _decision: &BoardDecision) -> Result<BoardDecision, String> {
384            unimplemented!()
385        }
386
387        async fn find_by_id(&self, id: Uuid) -> Result<Option<BoardDecision>, String> {
388            Ok(self
389                .decisions
390                .lock()
391                .unwrap()
392                .iter()
393                .find(|d| d.id == id)
394                .cloned())
395        }
396
397        async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<BoardDecision>, String> {
398            Ok(self
399                .decisions
400                .lock()
401                .unwrap()
402                .iter()
403                .filter(|d| d.building_id == building_id)
404                .cloned()
405                .collect())
406        }
407
408        async fn find_by_meeting(&self, meeting_id: Uuid) -> Result<Vec<BoardDecision>, String> {
409            Ok(self
410                .decisions
411                .lock()
412                .unwrap()
413                .iter()
414                .filter(|d| d.meeting_id == meeting_id)
415                .cloned()
416                .collect())
417        }
418
419        async fn find_by_status(
420            &self,
421            building_id: Uuid,
422            status: DecisionStatus,
423        ) -> Result<Vec<BoardDecision>, String> {
424            Ok(self
425                .decisions
426                .lock()
427                .unwrap()
428                .iter()
429                .filter(|d| d.building_id == building_id && d.status == status)
430                .cloned()
431                .collect())
432        }
433
434        async fn find_overdue(&self, building_id: Uuid) -> Result<Vec<BoardDecision>, String> {
435            Ok(self
436                .decisions
437                .lock()
438                .unwrap()
439                .iter()
440                .filter(|d| d.building_id == building_id && d.status == DecisionStatus::Overdue)
441                .cloned()
442                .collect())
443        }
444
445        async fn find_deadline_approaching(
446            &self,
447            building_id: Uuid,
448            days_threshold: i32,
449        ) -> Result<Vec<BoardDecision>, String> {
450            let now = Utc::now();
451            let threshold = now + chrono::Duration::days(days_threshold as i64);
452            Ok(self
453                .decisions
454                .lock()
455                .unwrap()
456                .iter()
457                .filter(|d| {
458                    if let Some(deadline) = d.deadline {
459                        d.building_id == building_id && deadline > now && deadline <= threshold
460                    } else {
461                        false
462                    }
463                })
464                .cloned()
465                .collect())
466        }
467
468        async fn update(&self, _decision: &BoardDecision) -> Result<BoardDecision, String> {
469            unimplemented!()
470        }
471
472        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
473            unimplemented!()
474        }
475
476        async fn count_by_status(
477            &self,
478            building_id: Uuid,
479            status: DecisionStatus,
480        ) -> Result<i64, String> {
481            Ok(self
482                .decisions
483                .lock()
484                .unwrap()
485                .iter()
486                .filter(|d| d.building_id == building_id && d.status == status)
487                .count() as i64)
488        }
489
490        async fn count_overdue(&self, building_id: Uuid) -> Result<i64, String> {
491            Ok(self
492                .decisions
493                .lock()
494                .unwrap()
495                .iter()
496                .filter(|d| d.building_id == building_id && d.status == DecisionStatus::Overdue)
497                .count() as i64)
498        }
499    }
500
501    // Mock BuildingRepository
502    struct MockBuildingRepository {
503        exists: bool,
504    }
505
506    impl MockBuildingRepository {
507        fn new(exists: bool) -> Self {
508            Self { exists }
509        }
510    }
511
512    #[async_trait]
513    impl BuildingRepository for MockBuildingRepository {
514        async fn create(
515            &self,
516            _building: &crate::domain::entities::Building,
517        ) -> Result<crate::domain::entities::Building, String> {
518            unimplemented!()
519        }
520
521        async fn find_by_id(
522            &self,
523            _id: Uuid,
524        ) -> Result<Option<crate::domain::entities::Building>, String> {
525            if self.exists {
526                Ok(Some(crate::domain::entities::Building {
527                    id: Uuid::new_v4(),
528                    organization_id: Uuid::new_v4(),
529                    name: "Test Building".to_string(),
530                    address: "123 Test St".to_string(),
531                    city: "Brussels".to_string(),
532                    postal_code: "1000".to_string(),
533                    country: "Belgium".to_string(),
534                    total_units: 25,
535                    total_tantiemes: 1000,
536                    construction_year: Some(2020),
537                    syndic_name: None,
538                    syndic_email: None,
539                    syndic_phone: None,
540                    syndic_address: None,
541                    syndic_office_hours: None,
542                    syndic_emergency_contact: None,
543                    slug: None,
544                    created_at: Utc::now(),
545                    updated_at: Utc::now(),
546                }))
547            } else {
548                Ok(None)
549            }
550        }
551
552        async fn find_all(&self) -> Result<Vec<crate::domain::entities::Building>, String> {
553            unimplemented!()
554        }
555
556        async fn find_all_paginated(
557            &self,
558            _page_request: &crate::application::dto::PageRequest,
559            _filters: &crate::application::dto::BuildingFilters,
560        ) -> Result<(Vec<crate::domain::entities::Building>, i64), String> {
561            unimplemented!()
562        }
563
564        async fn update(
565            &self,
566            _building: &crate::domain::entities::Building,
567        ) -> Result<crate::domain::entities::Building, String> {
568            unimplemented!()
569        }
570
571        async fn delete(&self, _id: Uuid) -> Result<bool, String> {
572            unimplemented!()
573        }
574
575        async fn find_by_slug(
576            &self,
577            _slug: &str,
578        ) -> Result<Option<crate::domain::entities::Building>, String> {
579            unimplemented!()
580        }
581    }
582
583    #[tokio::test]
584    async fn test_calculate_decision_stats() {
585        let board_member_repo = Arc::new(MockBoardMemberRepository::new());
586        let board_decision_repo = Arc::new(MockBoardDecisionRepository::new());
587        let building_repo = Arc::new(MockBuildingRepository::new(true));
588
589        let use_cases =
590            BoardDashboardUseCases::new(board_member_repo, board_decision_repo, building_repo);
591
592        let building_id = Uuid::new_v4();
593        let meeting_id = Uuid::new_v4();
594
595        let decisions = vec![
596            BoardDecision {
597                id: Uuid::new_v4(),
598                building_id,
599                meeting_id,
600                subject: "Decision 1".to_string(),
601                decision_text: "Text 1".to_string(),
602                deadline: None,
603                status: DecisionStatus::Pending,
604                completed_at: None,
605                notes: None,
606                created_at: Utc::now(),
607                updated_at: Utc::now(),
608            },
609            BoardDecision {
610                id: Uuid::new_v4(),
611                building_id,
612                meeting_id,
613                subject: "Decision 2".to_string(),
614                decision_text: "Text 2".to_string(),
615                deadline: None,
616                status: DecisionStatus::Pending,
617                completed_at: None,
618                notes: None,
619                created_at: Utc::now(),
620                updated_at: Utc::now(),
621            },
622            BoardDecision {
623                id: Uuid::new_v4(),
624                building_id,
625                meeting_id,
626                subject: "Decision 3".to_string(),
627                decision_text: "Text 3".to_string(),
628                deadline: None,
629                status: DecisionStatus::Completed,
630                completed_at: Some(Utc::now()),
631                notes: None,
632                created_at: Utc::now(),
633                updated_at: Utc::now(),
634            },
635            BoardDecision {
636                id: Uuid::new_v4(),
637                building_id,
638                meeting_id,
639                subject: "Decision 4".to_string(),
640                decision_text: "Text 4".to_string(),
641                deadline: Some(Utc::now() - chrono::Duration::days(5)),
642                status: DecisionStatus::Overdue,
643                completed_at: None,
644                notes: None,
645                created_at: Utc::now(),
646                updated_at: Utc::now(),
647            },
648        ];
649
650        let stats = use_cases.calculate_decision_stats(&decisions, building_id);
651
652        assert_eq!(stats.pending, 2);
653        assert_eq!(stats.completed, 1);
654        assert_eq!(stats.overdue, 1);
655        assert_eq!(stats.in_progress, 0);
656        assert_eq!(stats.total_decisions, 4);
657    }
658
659    #[tokio::test]
660    async fn test_get_upcoming_deadlines() {
661        let board_member_repo = Arc::new(MockBoardMemberRepository::new());
662        let board_decision_repo = Arc::new(MockBoardDecisionRepository::new());
663        let building_repo = Arc::new(MockBuildingRepository::new(true));
664
665        let use_cases =
666            BoardDashboardUseCases::new(board_member_repo, board_decision_repo, building_repo);
667
668        let building_id = Uuid::new_v4();
669        let meeting_id = Uuid::new_v4();
670
671        let decisions = vec![
672            BoardDecision {
673                id: Uuid::new_v4(),
674                building_id,
675                meeting_id,
676                subject: "Critical Decision".to_string(),
677                decision_text: "Text".to_string(),
678                deadline: Some(Utc::now() + chrono::Duration::days(5)),
679                status: DecisionStatus::Pending,
680                completed_at: None,
681                notes: None,
682                created_at: Utc::now(),
683                updated_at: Utc::now(),
684            },
685            BoardDecision {
686                id: Uuid::new_v4(),
687                building_id,
688                meeting_id,
689                subject: "High Priority".to_string(),
690                decision_text: "Text".to_string(),
691                deadline: Some(Utc::now() + chrono::Duration::days(10)),
692                status: DecisionStatus::Pending,
693                completed_at: None,
694                notes: None,
695                created_at: Utc::now(),
696                updated_at: Utc::now(),
697            },
698            BoardDecision {
699                id: Uuid::new_v4(),
700                building_id,
701                meeting_id,
702                subject: "Medium Priority".to_string(),
703                decision_text: "Text".to_string(),
704                deadline: Some(Utc::now() + chrono::Duration::days(20)),
705                status: DecisionStatus::Pending,
706                completed_at: None,
707                notes: None,
708                created_at: Utc::now(),
709                updated_at: Utc::now(),
710            },
711            BoardDecision {
712                id: Uuid::new_v4(),
713                building_id,
714                meeting_id,
715                subject: "Too Far".to_string(),
716                decision_text: "Text".to_string(),
717                deadline: Some(Utc::now() + chrono::Duration::days(60)),
718                status: DecisionStatus::Pending,
719                completed_at: None,
720                notes: None,
721                created_at: Utc::now(),
722                updated_at: Utc::now(),
723            },
724        ];
725
726        let deadlines = use_cases.get_upcoming_deadlines(&decisions);
727
728        assert_eq!(deadlines.len(), 3);
729        assert_eq!(deadlines[0].urgency, "critical");
730        assert_eq!(deadlines[1].urgency, "high");
731        assert_eq!(deadlines[2].urgency, "medium");
732    }
733
734    #[tokio::test]
735    async fn test_dashboard_with_expiring_mandate() {
736        let board_member_repo = Arc::new(MockBoardMemberRepository::new());
737        let board_decision_repo = Arc::new(MockBoardDecisionRepository::new());
738        let building_repo = Arc::new(MockBuildingRepository::new(true));
739
740        let building_id = Uuid::new_v4();
741        let owner_id = Uuid::new_v4();
742        let meeting_id = Uuid::new_v4();
743
744        // Add board member with expiring mandate (45 days)
745        let mandate_start = Utc::now() - chrono::Duration::days(320);
746        let mandate_end = mandate_start + chrono::Duration::days(365);
747
748        board_member_repo.add_member(BoardMember {
749            id: Uuid::new_v4(),
750            owner_id,
751            building_id,
752            position: BoardPosition::President,
753            mandate_start,
754            mandate_end,
755            elected_by_meeting_id: meeting_id,
756            created_at: Utc::now(),
757            updated_at: Utc::now(),
758        });
759
760        // Add some decisions
761        board_decision_repo.add_decision(BoardDecision {
762            id: Uuid::new_v4(),
763            building_id,
764            meeting_id,
765            subject: "Pending Decision".to_string(),
766            decision_text: "Text".to_string(),
767            deadline: Some(Utc::now() + chrono::Duration::days(15)),
768            status: DecisionStatus::Pending,
769            completed_at: None,
770            notes: None,
771            created_at: Utc::now(),
772            updated_at: Utc::now(),
773        });
774
775        let use_cases =
776            BoardDashboardUseCases::new(board_member_repo, board_decision_repo, building_repo);
777
778        let dashboard = use_cases
779            .get_dashboard(building_id, owner_id)
780            .await
781            .expect("Should get dashboard");
782
783        // Verify mandate info
784        assert!(dashboard.my_mandate.is_some());
785        let mandate = dashboard.my_mandate.unwrap();
786        assert!(mandate.expires_soon);
787
788        // Verify stats
789        assert_eq!(dashboard.decisions_stats.pending, 1);
790    }
791}