1use crate::application::dto::age_request_dto::{
2 AddCosignatoryDto, AgeRequestResponseDto, CreateAgeRequestDto, SyndicResponseDto,
3};
4use crate::application::ports::age_request_repository::AgeRequestRepository;
5use crate::domain::entities::age_request::{AgeRequest, AgeRequestCosignatory};
6use std::sync::Arc;
7use uuid::Uuid;
8
9pub struct AgeRequestUseCases {
10 pub repo: Arc<dyn AgeRequestRepository>,
11}
12
13impl AgeRequestUseCases {
14 pub fn new(repo: Arc<dyn AgeRequestRepository>) -> Self {
15 Self { repo }
16 }
17
18 pub async fn create(
20 &self,
21 organization_id: Uuid,
22 created_by: Uuid,
23 dto: CreateAgeRequestDto,
24 ) -> Result<AgeRequestResponseDto, String> {
25 let age_request = AgeRequest::new(
26 organization_id,
27 dto.building_id,
28 dto.title,
29 dto.description,
30 created_by,
31 )?;
32 let saved = self.repo.create(&age_request).await?;
33 Ok(AgeRequestResponseDto::from(&saved))
34 }
35
36 pub async fn get(
38 &self,
39 id: Uuid,
40 organization_id: Uuid,
41 ) -> Result<AgeRequestResponseDto, String> {
42 let req = self
43 .repo
44 .find_by_id(id)
45 .await?
46 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
47
48 if req.organization_id != organization_id {
49 return Err("Accès refusé".to_string());
50 }
51
52 Ok(AgeRequestResponseDto::from(&req))
53 }
54
55 pub async fn list_by_building(
57 &self,
58 building_id: Uuid,
59 organization_id: Uuid,
60 ) -> Result<Vec<AgeRequestResponseDto>, String> {
61 let requests = self.repo.find_by_building(building_id).await?;
62 let filtered: Vec<_> = requests
64 .iter()
65 .filter(|r| r.organization_id == organization_id)
66 .map(AgeRequestResponseDto::from)
67 .collect();
68 Ok(filtered)
69 }
70
71 pub async fn open(
73 &self,
74 id: Uuid,
75 organization_id: Uuid,
76 requester_id: Uuid,
77 ) -> Result<AgeRequestResponseDto, String> {
78 let mut req = self
79 .repo
80 .find_by_id(id)
81 .await?
82 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
83
84 if req.organization_id != organization_id {
85 return Err("Accès refusé".to_string());
86 }
87 if req.created_by != requester_id {
88 return Err("Seul l'initiateur peut ouvrir cette demande".to_string());
89 }
90
91 req.open()?;
92 let updated = self.repo.update(&req).await?;
93 Ok(AgeRequestResponseDto::from(&updated))
94 }
95
96 pub async fn add_cosignatory(
99 &self,
100 id: Uuid,
101 organization_id: Uuid,
102 dto: AddCosignatoryDto,
103 ) -> Result<AgeRequestResponseDto, String> {
104 let mut req = self
105 .repo
106 .find_by_id(id)
107 .await?
108 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
109
110 if req.organization_id != organization_id {
111 return Err("Accès refusé".to_string());
112 }
113
114 let _newly_reached = req.add_cosignatory(dto.owner_id, dto.shares_pct)?;
115
116 let updated = self.repo.update(&req).await?;
118
119 let cosignatory = AgeRequestCosignatory::new(id, dto.owner_id, dto.shares_pct)?;
121 self.repo.add_cosignatory(&cosignatory).await?;
122
123 let full = self.repo.find_by_id(id).await?.unwrap_or(updated);
125
126 Ok(AgeRequestResponseDto::from(&full))
127 }
128
129 pub async fn remove_cosignatory(
131 &self,
132 id: Uuid,
133 owner_id: Uuid,
134 organization_id: Uuid,
135 ) -> Result<AgeRequestResponseDto, String> {
136 let mut req = self
137 .repo
138 .find_by_id(id)
139 .await?
140 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
141
142 if req.organization_id != organization_id {
143 return Err("Accès refusé".to_string());
144 }
145
146 req.remove_cosignatory(owner_id)?;
147
148 self.repo.remove_cosignatory(id, owner_id).await?;
150
151 let updated = self.repo.update(&req).await?;
153 let full = self.repo.find_by_id(id).await?.unwrap_or(updated);
154 Ok(AgeRequestResponseDto::from(&full))
155 }
156
157 pub async fn submit_to_syndic(
160 &self,
161 id: Uuid,
162 organization_id: Uuid,
163 requester_id: Uuid,
164 ) -> Result<AgeRequestResponseDto, String> {
165 let mut req = self
166 .repo
167 .find_by_id(id)
168 .await?
169 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
170
171 if req.organization_id != organization_id {
172 return Err("Accès refusé".to_string());
173 }
174 if req.created_by != requester_id {
175 return Err("Seul l'initiateur peut soumettre cette demande au syndic".to_string());
176 }
177
178 req.submit_to_syndic()?;
179 let updated = self.repo.update(&req).await?;
180 Ok(AgeRequestResponseDto::from(&updated))
181 }
182
183 pub async fn syndic_response(
185 &self,
186 id: Uuid,
187 organization_id: Uuid,
188 dto: SyndicResponseDto,
189 ) -> Result<AgeRequestResponseDto, String> {
190 let mut req = self
191 .repo
192 .find_by_id(id)
193 .await?
194 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
195
196 if req.organization_id != organization_id {
197 return Err("Accès refusé".to_string());
198 }
199
200 if dto.accepted {
201 req.accept_by_syndic(dto.notes)?;
202 } else {
203 let reason = dto
204 .notes
205 .ok_or_else(|| "Un motif de refus est obligatoire".to_string())?;
206 req.reject_by_syndic(reason)?;
207 }
208
209 let updated = self.repo.update(&req).await?;
210 Ok(AgeRequestResponseDto::from(&updated))
211 }
212
213 pub async fn trigger_auto_convocation(
216 &self,
217 id: Uuid,
218 organization_id: Uuid,
219 ) -> Result<AgeRequestResponseDto, String> {
220 let mut req = self
221 .repo
222 .find_by_id(id)
223 .await?
224 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
225
226 if req.organization_id != organization_id {
227 return Err("Accès refusé".to_string());
228 }
229
230 req.trigger_auto_convocation()?;
231 let updated = self.repo.update(&req).await?;
232 Ok(AgeRequestResponseDto::from(&updated))
233 }
234
235 pub async fn withdraw(
237 &self,
238 id: Uuid,
239 organization_id: Uuid,
240 requester_id: Uuid,
241 ) -> Result<AgeRequestResponseDto, String> {
242 let mut req = self
243 .repo
244 .find_by_id(id)
245 .await?
246 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
247
248 if req.organization_id != organization_id {
249 return Err("Accès refusé".to_string());
250 }
251
252 req.withdraw(requester_id)?;
253 let updated = self.repo.update(&req).await?;
254 Ok(AgeRequestResponseDto::from(&updated))
255 }
256
257 pub async fn process_expired_deadlines(&self) -> Result<usize, String> {
259 let expired = self.repo.find_expired_deadlines().await?;
260 let count = expired.len();
261
262 for mut req in expired {
263 if let Err(e) = req.trigger_auto_convocation() {
264 eprintln!(
266 "Erreur trigger_auto_convocation pour demande {}: {}",
267 req.id, e
268 );
269 continue;
270 }
271 if let Err(e) = self.repo.update(&req).await {
272 eprintln!(
273 "Erreur update demande {} lors de l'auto-convocation: {}",
274 req.id, e
275 );
276 }
277 }
278
279 Ok(count)
280 }
281
282 pub async fn delete(
284 &self,
285 id: Uuid,
286 organization_id: Uuid,
287 requester_id: Uuid,
288 ) -> Result<(), String> {
289 let req = self
290 .repo
291 .find_by_id(id)
292 .await?
293 .ok_or_else(|| format!("Demande AGE {} introuvable", id))?;
294
295 if req.organization_id != organization_id {
296 return Err("Accès refusé".to_string());
297 }
298 if req.created_by != requester_id {
299 return Err("Seul l'initiateur peut supprimer cette demande".to_string());
300 }
301
302 use crate::domain::entities::age_request::AgeRequestStatus;
303 if req.status != AgeRequestStatus::Draft && req.status != AgeRequestStatus::Withdrawn {
304 return Err(format!(
305 "Impossible de supprimer une demande en statut {:?}. \
306 Seules les demandes Draft ou Withdrawn peuvent être supprimées.",
307 req.status
308 ));
309 }
310
311 self.repo.delete(id).await?;
312 Ok(())
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::application::dto::age_request_dto::{
320 AddCosignatoryDto, CreateAgeRequestDto, SyndicResponseDto,
321 };
322 use crate::application::ports::age_request_repository::AgeRequestRepository;
323 use crate::domain::entities::age_request::{
324 AgeRequest, AgeRequestCosignatory, AgeRequestStatus,
325 };
326 use async_trait::async_trait;
327 use chrono::{Duration, Utc};
328 use std::collections::HashMap;
329 use std::sync::Mutex;
330 use uuid::Uuid;
331
332 struct MockAgeRequestRepository {
337 requests: Mutex<HashMap<Uuid, AgeRequest>>,
338 cosignatories: Mutex<HashMap<Uuid, Vec<AgeRequestCosignatory>>>,
339 }
340
341 impl MockAgeRequestRepository {
342 fn new() -> Self {
343 Self {
344 requests: Mutex::new(HashMap::new()),
345 cosignatories: Mutex::new(HashMap::new()),
346 }
347 }
348
349 fn seed(&self, req: AgeRequest) {
352 self.requests.lock().unwrap().insert(req.id, req);
353 }
354 }
355
356 #[async_trait]
357 impl AgeRequestRepository for MockAgeRequestRepository {
358 async fn create(&self, age_request: &AgeRequest) -> Result<AgeRequest, String> {
359 self.requests
360 .lock()
361 .unwrap()
362 .insert(age_request.id, age_request.clone());
363 Ok(age_request.clone())
364 }
365
366 async fn find_by_id(&self, id: Uuid) -> Result<Option<AgeRequest>, String> {
367 Ok(self.requests.lock().unwrap().get(&id).cloned())
368 }
369
370 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<AgeRequest>, String> {
371 Ok(self
372 .requests
373 .lock()
374 .unwrap()
375 .values()
376 .filter(|r| r.building_id == building_id)
377 .cloned()
378 .collect())
379 }
380
381 async fn find_by_organization(
382 &self,
383 organization_id: Uuid,
384 ) -> Result<Vec<AgeRequest>, String> {
385 Ok(self
386 .requests
387 .lock()
388 .unwrap()
389 .values()
390 .filter(|r| r.organization_id == organization_id)
391 .cloned()
392 .collect())
393 }
394
395 async fn update(&self, age_request: &AgeRequest) -> Result<AgeRequest, String> {
396 self.requests
397 .lock()
398 .unwrap()
399 .insert(age_request.id, age_request.clone());
400 Ok(age_request.clone())
401 }
402
403 async fn delete(&self, id: Uuid) -> Result<bool, String> {
404 Ok(self.requests.lock().unwrap().remove(&id).is_some())
405 }
406
407 async fn add_cosignatory(&self, cosignatory: &AgeRequestCosignatory) -> Result<(), String> {
408 self.cosignatories
409 .lock()
410 .unwrap()
411 .entry(cosignatory.age_request_id)
412 .or_default()
413 .push(cosignatory.clone());
414 Ok(())
415 }
416
417 async fn remove_cosignatory(
418 &self,
419 age_request_id: Uuid,
420 owner_id: Uuid,
421 ) -> Result<bool, String> {
422 let mut map = self.cosignatories.lock().unwrap();
423 if let Some(list) = map.get_mut(&age_request_id) {
424 let before = list.len();
425 list.retain(|c| c.owner_id != owner_id);
426 Ok(list.len() < before)
427 } else {
428 Ok(false)
429 }
430 }
431
432 async fn find_cosignatories(
433 &self,
434 age_request_id: Uuid,
435 ) -> Result<Vec<AgeRequestCosignatory>, String> {
436 Ok(self
437 .cosignatories
438 .lock()
439 .unwrap()
440 .get(&age_request_id)
441 .cloned()
442 .unwrap_or_default())
443 }
444
445 async fn find_expired_deadlines(&self) -> Result<Vec<AgeRequest>, String> {
446 let now = Utc::now();
447 Ok(self
448 .requests
449 .lock()
450 .unwrap()
451 .values()
452 .filter(|r| {
453 r.status == AgeRequestStatus::Submitted
454 && r.syndic_deadline_at.map(|d| now > d).unwrap_or(false)
455 })
456 .cloned()
457 .collect())
458 }
459 }
460
461 fn make_use_cases(repo: Arc<MockAgeRequestRepository>) -> AgeRequestUseCases {
466 AgeRequestUseCases::new(repo)
467 }
468
469 fn org_id() -> Uuid {
470 Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
471 }
472
473 fn building_id() -> Uuid {
474 Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()
475 }
476
477 fn creator_id() -> Uuid {
478 Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap()
479 }
480
481 async fn create_draft(uc: &AgeRequestUseCases) -> Uuid {
483 let dto = CreateAgeRequestDto {
484 building_id: building_id(),
485 title: "Remplacement toiture".to_string(),
486 description: Some("Infiltrations importantes".to_string()),
487 };
488 let resp = uc.create(org_id(), creator_id(), dto).await.unwrap();
489 resp.id
490 }
491
492 async fn create_reached(uc: &AgeRequestUseCases, repo: &MockAgeRequestRepository) -> Uuid {
495 let id = create_draft(uc).await;
496
497 uc.open(id, org_id(), creator_id()).await.unwrap();
499
500 let owner1 = Uuid::new_v4();
502 let owner2 = Uuid::new_v4();
503 uc.add_cosignatory(
504 id,
505 org_id(),
506 AddCosignatoryDto {
507 owner_id: owner1,
508 shares_pct: 0.12,
509 },
510 )
511 .await
512 .unwrap();
513 uc.add_cosignatory(
514 id,
515 org_id(),
516 AddCosignatoryDto {
517 owner_id: owner2,
518 shares_pct: 0.10,
519 },
520 )
521 .await
522 .unwrap();
523
524 let req = repo.find_by_id(id).await.unwrap().unwrap();
526 assert_eq!(req.status, AgeRequestStatus::Reached);
527
528 id
529 }
530
531 async fn create_submitted(uc: &AgeRequestUseCases, repo: &MockAgeRequestRepository) -> Uuid {
533 let id = create_reached(uc, repo).await;
534 uc.submit_to_syndic(id, org_id(), creator_id())
535 .await
536 .unwrap();
537
538 let req = repo.find_by_id(id).await.unwrap().unwrap();
539 assert_eq!(req.status, AgeRequestStatus::Submitted);
540 id
541 }
542
543 #[tokio::test]
548 async fn test_create_age_request_success() {
549 let repo = Arc::new(MockAgeRequestRepository::new());
550 let uc = make_use_cases(repo.clone());
551
552 let dto = CreateAgeRequestDto {
553 building_id: building_id(),
554 title: "Remplacement toiture".to_string(),
555 description: Some("Infiltrations importantes".to_string()),
556 };
557
558 let resp = uc.create(org_id(), creator_id(), dto).await.unwrap();
559
560 assert_eq!(resp.status, "draft");
561 assert_eq!(resp.title, "Remplacement toiture");
562 assert_eq!(resp.organization_id, org_id());
563 assert_eq!(resp.building_id, building_id());
564 assert_eq!(resp.created_by, creator_id());
565 assert_eq!(resp.total_shares_pct, 0.0);
566 assert!(!resp.threshold_reached);
567 assert_eq!(resp.threshold_pct, AgeRequest::DEFAULT_THRESHOLD_PCT);
568 assert!(resp.cosignatories.is_empty());
569
570 let stored = repo.find_by_id(resp.id).await.unwrap();
572 assert!(stored.is_some());
573 }
574
575 #[tokio::test]
576 async fn test_add_cosignatory_threshold_logic() {
577 let repo = Arc::new(MockAgeRequestRepository::new());
578 let uc = make_use_cases(repo.clone());
579
580 let id = create_draft(&uc).await;
581 uc.open(id, org_id(), creator_id()).await.unwrap();
582
583 let owner1 = Uuid::new_v4();
585 let resp = uc
586 .add_cosignatory(
587 id,
588 org_id(),
589 AddCosignatoryDto {
590 owner_id: owner1,
591 shares_pct: 0.10,
592 },
593 )
594 .await
595 .unwrap();
596 assert_eq!(resp.status, "open");
597 assert!(!resp.threshold_reached);
598 assert!((resp.total_shares_pct - 0.10).abs() < 1e-9);
599
600 let owner2 = Uuid::new_v4();
602 let resp = uc
603 .add_cosignatory(
604 id,
605 org_id(),
606 AddCosignatoryDto {
607 owner_id: owner2,
608 shares_pct: 0.12,
609 },
610 )
611 .await
612 .unwrap();
613 assert_eq!(resp.status, "reached");
614 assert!(resp.threshold_reached);
615 assert!(resp.threshold_reached_at.is_some());
616 assert!((resp.total_shares_pct - 0.22).abs() < 1e-9);
617 assert_eq!(resp.shares_pct_missing, 0.0);
618 }
619
620 #[tokio::test]
621 async fn test_submit_to_syndic_when_threshold_reached() {
622 let repo = Arc::new(MockAgeRequestRepository::new());
623 let uc = make_use_cases(repo.clone());
624
625 let id = create_reached(&uc, &repo).await;
626
627 let resp = uc
628 .submit_to_syndic(id, org_id(), creator_id())
629 .await
630 .unwrap();
631
632 assert_eq!(resp.status, "submitted");
633 assert!(resp.submitted_to_syndic_at.is_some());
634 assert!(resp.syndic_deadline_at.is_some());
635
636 let submitted = resp.submitted_to_syndic_at.unwrap();
638 let deadline = resp.syndic_deadline_at.unwrap();
639 let diff = deadline - submitted;
640 assert_eq!(diff.num_days(), AgeRequest::SYNDIC_DEADLINE_DAYS);
641 }
642
643 #[tokio::test]
644 async fn test_submit_to_syndic_rejected_when_threshold_not_reached() {
645 let repo = Arc::new(MockAgeRequestRepository::new());
646 let uc = make_use_cases(repo.clone());
647
648 let id = create_draft(&uc).await;
649 uc.open(id, org_id(), creator_id()).await.unwrap();
650
651 uc.add_cosignatory(
653 id,
654 org_id(),
655 AddCosignatoryDto {
656 owner_id: Uuid::new_v4(),
657 shares_pct: 0.05,
658 },
659 )
660 .await
661 .unwrap();
662
663 let result = uc.submit_to_syndic(id, org_id(), creator_id()).await;
665 assert!(result.is_err());
666 let err = result.unwrap_err();
667 assert!(
668 err.contains("Reached"),
669 "Error should mention Reached status requirement, got: {}",
670 err
671 );
672
673 let req = repo.find_by_id(id).await.unwrap().unwrap();
675 assert_eq!(req.status, AgeRequestStatus::Open);
676 }
677
678 #[tokio::test]
679 async fn test_syndic_accepts_request() {
680 let repo = Arc::new(MockAgeRequestRepository::new());
681 let uc = make_use_cases(repo.clone());
682
683 let id = create_submitted(&uc, &repo).await;
684
685 let resp = uc
686 .syndic_response(
687 id,
688 org_id(),
689 SyndicResponseDto {
690 accepted: true,
691 notes: Some("Convocation prévue le 15/04".to_string()),
692 },
693 )
694 .await
695 .unwrap();
696
697 assert_eq!(resp.status, "accepted");
698 assert!(resp.syndic_response_at.is_some());
699 assert_eq!(
700 resp.syndic_notes.as_deref(),
701 Some("Convocation prévue le 15/04")
702 );
703 }
704
705 #[tokio::test]
706 async fn test_syndic_rejects_request_with_reason() {
707 let repo = Arc::new(MockAgeRequestRepository::new());
708 let uc = make_use_cases(repo.clone());
709
710 let id = create_submitted(&uc, &repo).await;
711
712 let resp = uc
713 .syndic_response(
714 id,
715 org_id(),
716 SyndicResponseDto {
717 accepted: false,
718 notes: Some("Demande insuffisamment motivée".to_string()),
719 },
720 )
721 .await
722 .unwrap();
723
724 assert_eq!(resp.status, "rejected");
725 assert!(resp.syndic_response_at.is_some());
726 assert_eq!(
727 resp.syndic_notes.as_deref(),
728 Some("Demande insuffisamment motivée")
729 );
730 }
731
732 #[tokio::test]
733 async fn test_syndic_rejects_without_reason_fails() {
734 let repo = Arc::new(MockAgeRequestRepository::new());
735 let uc = make_use_cases(repo.clone());
736
737 let id = create_submitted(&uc, &repo).await;
738
739 let result = uc
741 .syndic_response(
742 id,
743 org_id(),
744 SyndicResponseDto {
745 accepted: false,
746 notes: None,
747 },
748 )
749 .await;
750
751 assert!(result.is_err());
752 let err = result.unwrap_err();
753 assert!(
754 err.contains("motif") || err.contains("obligatoire"),
755 "Error should mention mandatory reason, got: {}",
756 err
757 );
758 }
759
760 #[tokio::test]
761 async fn test_withdraw_request_by_initiator() {
762 let repo = Arc::new(MockAgeRequestRepository::new());
763 let uc = make_use_cases(repo.clone());
764
765 let id = create_draft(&uc).await;
766 uc.open(id, org_id(), creator_id()).await.unwrap();
767
768 let resp = uc.withdraw(id, org_id(), creator_id()).await.unwrap();
770 assert_eq!(resp.status, "withdrawn");
771
772 let req = repo.find_by_id(id).await.unwrap().unwrap();
774 assert_eq!(req.status, AgeRequestStatus::Withdrawn);
775 }
776
777 #[tokio::test]
778 async fn test_deadline_15_days_logic() {
779 let repo = Arc::new(MockAgeRequestRepository::new());
780 let uc = make_use_cases(repo.clone());
781
782 let mut req = AgeRequest::new(
785 org_id(),
786 building_id(),
787 "Toiture urgente".to_string(),
788 None,
789 creator_id(),
790 )
791 .unwrap();
792 req.open().unwrap();
793 req.add_cosignatory(Uuid::new_v4(), 0.25).unwrap();
794 req.submit_to_syndic().unwrap();
795
796 let past_submitted = Utc::now() - Duration::days(16);
798 req.submitted_to_syndic_at = Some(past_submitted);
799 req.syndic_deadline_at =
800 Some(past_submitted + Duration::days(AgeRequest::SYNDIC_DEADLINE_DAYS));
801
802 let request_id = req.id;
803 repo.seed(req);
804
805 let resp = uc
807 .trigger_auto_convocation(request_id, org_id())
808 .await
809 .unwrap();
810
811 assert_eq!(resp.status, "expired");
812 assert!(resp.auto_convocation_triggered);
813
814 let req = repo.find_by_id(request_id).await.unwrap().unwrap();
817 assert_eq!(req.status, AgeRequestStatus::Expired);
818 assert!(req.auto_convocation_triggered);
819 }
820}