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 }
309 }
310
311 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 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 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 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 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 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 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 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 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); 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 decision_repo
572 .expect_find_by_id()
573 .with(eq(decision_id))
574 .times(1)
575 .returning(move |_| Ok(Some(decision.clone())));
576
577 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}