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
14pub 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#[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 pub async fn get_dashboard(
46 &self,
47 building_id: Uuid,
48 owner_id: Uuid,
49 ) -> Result<BoardDashboardResponse, String> {
50 self.building_repo
52 .find_by_id(building_id)
53 .await?
54 .ok_or_else(|| "Building not found".to_string())?;
55
56 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 let decisions = self
65 .board_decision_repo
66 .find_by_building(building_id)
67 .await?;
68
69 let decisions_stats = self.calculate_decision_stats(&decisions, building_id);
71
72 let overdue_decisions = self.get_overdue_decisions(&decisions);
74
75 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 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 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 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 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 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 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 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 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 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 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 assert!(dashboard.my_mandate.is_some());
785 let mandate = dashboard.my_mandate.unwrap();
786 assert!(mandate.expires_soon);
787
788 assert_eq!(dashboard.decisions_stats.pending, 1);
790 }
791}