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
11pub 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 pub async fn create_decision(
33 &self,
34 dto: CreateBoardDecisionDto,
35 ) -> Result<BoardDecisionResponseDto, String> {
36 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 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 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 let decision = BoardDecision::new(
67 building_id,
68 meeting_id,
69 dto.subject,
70 dto.decision_text,
71 deadline,
72 )?;
73
74 let created = self.decision_repository.create(&decision).await?;
76
77 Ok(Self::to_response_dto(created))
78 }
79
80 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 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 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 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 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 let new_status = dto
146 .status
147 .parse::<DecisionStatus>()
148 .map_err(|e| e.to_string())?;
149 decision.update_status(new_status)?;
150
151 if let Some(notes) = dto.notes {
153 decision.add_notes(notes);
154 }
155
156 decision.check_and_update_overdue_status();
158
159 let updated = self.decision_repository.update(&decision).await?;
161
162 Ok(Self::to_response_dto(updated))
163 }
164
165 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 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 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 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! {
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! {
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! {
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 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 use crate::domain::entities::MeetingType;
365 let meeting = Meeting::new(
366 Uuid::new_v4(), 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 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 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 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 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 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 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); 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 decision_repo
577 .expect_find_by_id()
578 .with(eq(decision_id))
579 .times(1)
580 .returning(move |_| Ok(Some(decision.clone())));
581
582 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}