koprogo_api/application/use_cases/
contractor_report_use_cases.rs

1use crate::application::dto::contractor_report_dto::{
2    ContractorReportResponseDto, CreateContractorReportDto, MagicLinkResponseDto, RejectReportDto,
3    RequestCorrectionsDto, UpdateContractorReportDto,
4};
5use crate::application::ports::contractor_report_repository::ContractorReportRepository;
6use crate::domain::entities::contractor_report::{ContractorReport, ContractorReportStatus};
7use chrono::{Duration, Utc};
8use std::sync::Arc;
9use uuid::Uuid;
10
11/// Durée de validité du magic link (72 heures)
12const MAGIC_LINK_VALIDITY_HOURS: i64 = 72;
13
14pub struct ContractorReportUseCases {
15    pub repo: Arc<dyn ContractorReportRepository>,
16}
17
18impl ContractorReportUseCases {
19    pub fn new(repo: Arc<dyn ContractorReportRepository>) -> Self {
20        Self { repo }
21    }
22
23    /// Crée un nouveau rapport de travaux (B16-1)
24    pub async fn create(
25        &self,
26        organization_id: Uuid,
27        dto: CreateContractorReportDto,
28    ) -> Result<ContractorReportResponseDto, String> {
29        let report = ContractorReport::new(
30            organization_id,
31            dto.building_id,
32            dto.contractor_name,
33            dto.ticket_id,
34            dto.quote_id,
35            dto.contractor_user_id,
36        )?;
37        let saved = self.repo.create(&report).await?;
38        Ok(ContractorReportResponseDto::from(&saved))
39    }
40
41    /// Récupère un rapport par son ID (vérification organisation)
42    pub async fn get(
43        &self,
44        id: Uuid,
45        organization_id: Uuid,
46    ) -> Result<ContractorReportResponseDto, String> {
47        let report = self
48            .repo
49            .find_by_id(id)
50            .await?
51            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
52
53        if report.organization_id != organization_id {
54            return Err("Accès refusé".to_string());
55        }
56        Ok(ContractorReportResponseDto::from(&report))
57    }
58
59    /// Récupère un rapport via magic token (accès PWA sans auth)
60    pub async fn get_by_token(
61        &self,
62        token_hash: &str,
63    ) -> Result<ContractorReportResponseDto, String> {
64        let report = self
65            .repo
66            .find_by_magic_token(token_hash)
67            .await?
68            .ok_or("Lien invalide ou expiré".to_string())?;
69
70        if !report.is_magic_token_valid() {
71            return Err("Le lien magic a expiré (validité 72h)".to_string());
72        }
73        Ok(ContractorReportResponseDto::from(&report))
74    }
75
76    /// Liste les rapports d'un bâtiment
77    pub async fn list_by_building(
78        &self,
79        building_id: Uuid,
80        organization_id: Uuid,
81    ) -> Result<Vec<ContractorReportResponseDto>, String> {
82        let reports = self.repo.find_by_building(building_id).await?;
83        Ok(reports
84            .iter()
85            .filter(|r| r.organization_id == organization_id)
86            .map(ContractorReportResponseDto::from)
87            .collect())
88    }
89
90    /// Liste les rapports d'un ticket
91    pub async fn list_by_ticket(
92        &self,
93        ticket_id: Uuid,
94        organization_id: Uuid,
95    ) -> Result<Vec<ContractorReportResponseDto>, String> {
96        let reports = self.repo.find_by_ticket(ticket_id).await?;
97        Ok(reports
98            .iter()
99            .filter(|r| r.organization_id == organization_id)
100            .map(ContractorReportResponseDto::from)
101            .collect())
102    }
103
104    /// Met à jour le brouillon du rapport (photos, pièces, compte-rendu)
105    pub async fn update(
106        &self,
107        id: Uuid,
108        organization_id: Uuid,
109        dto: UpdateContractorReportDto,
110    ) -> Result<ContractorReportResponseDto, String> {
111        let mut report = self
112            .repo
113            .find_by_id(id)
114            .await?
115            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
116
117        if report.organization_id != organization_id {
118            return Err("Accès refusé".to_string());
119        }
120        if report.status != ContractorReportStatus::Draft
121            && report.status != ContractorReportStatus::RequiresCorrection
122        {
123            return Err(format!(
124                "Impossible de modifier depuis l'état {:?}",
125                report.status
126            ));
127        }
128
129        if let Some(date) = dto.work_date {
130            report.work_date = Some(date);
131        }
132        if let Some(cr) = dto.compte_rendu {
133            report.compte_rendu = Some(cr);
134        }
135        if let Some(photos) = dto.photos_before {
136            report.photos_before = photos;
137        }
138        if let Some(photos) = dto.photos_after {
139            report.photos_after = photos;
140        }
141        if let Some(parts) = dto.parts_replaced {
142            report.parts_replaced = parts.into_iter().map(Into::into).collect();
143        }
144        report.updated_at = Utc::now();
145
146        let saved = self.repo.update(&report).await?;
147        Ok(ContractorReportResponseDto::from(&saved))
148    }
149
150    /// Corps de métier soumet le rapport pour validation CdC (B16-3)
151    pub async fn submit(
152        &self,
153        id: Uuid,
154        organization_id: Uuid,
155    ) -> Result<ContractorReportResponseDto, String> {
156        let mut report = self
157            .repo
158            .find_by_id(id)
159            .await?
160            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
161
162        if report.organization_id != organization_id {
163            return Err("Accès refusé".to_string());
164        }
165        report.submit()?;
166        let saved = self.repo.update(&report).await?;
167        Ok(ContractorReportResponseDto::from(&saved))
168    }
169
170    /// Accès via magic link : soumet le rapport sans authentification classique
171    pub async fn submit_by_token(
172        &self,
173        token_hash: &str,
174    ) -> Result<ContractorReportResponseDto, String> {
175        let mut report = self
176            .repo
177            .find_by_magic_token(token_hash)
178            .await?
179            .ok_or("Lien invalide ou expiré".to_string())?;
180
181        if !report.is_magic_token_valid() {
182            return Err("Le lien magic a expiré (validité 72h)".to_string());
183        }
184        report.submit()?;
185        let saved = self.repo.update(&report).await?;
186        Ok(ContractorReportResponseDto::from(&saved))
187    }
188
189    /// CdC valide le rapport → paiement automatique déclenché (B16-6)
190    ///
191    /// Dans une implémentation complète, on ferait appel au PaymentUseCases ici.
192    /// Pour l'instant on retourne le rapport validé et on documente le hook.
193    pub async fn validate(
194        &self,
195        id: Uuid,
196        organization_id: Uuid,
197        validated_by: Uuid,
198    ) -> Result<ContractorReportResponseDto, String> {
199        let mut report = self
200            .repo
201            .find_by_id(id)
202            .await?
203            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
204
205        if report.organization_id != organization_id {
206            return Err("Accès refusé".to_string());
207        }
208        report.validate(validated_by)?;
209        let saved = self.repo.update(&report).await?;
210
211        // TODO (B16-6) : déclencher paiement automatique si quote_id présent
212        // payment_use_cases.trigger_contractor_payment(saved.quote_id, saved.id).await?;
213
214        Ok(ContractorReportResponseDto::from(&saved))
215    }
216
217    /// CdC demande des corrections au corps de métier
218    pub async fn request_corrections(
219        &self,
220        id: Uuid,
221        organization_id: Uuid,
222        dto: RequestCorrectionsDto,
223    ) -> Result<ContractorReportResponseDto, String> {
224        let mut report = self
225            .repo
226            .find_by_id(id)
227            .await?
228            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
229
230        if report.organization_id != organization_id {
231            return Err("Accès refusé".to_string());
232        }
233        report.request_corrections(dto.comments)?;
234        let saved = self.repo.update(&report).await?;
235        Ok(ContractorReportResponseDto::from(&saved))
236    }
237
238    /// CdC rejette le rapport
239    pub async fn reject(
240        &self,
241        id: Uuid,
242        organization_id: Uuid,
243        dto: RejectReportDto,
244        rejected_by: Uuid,
245    ) -> Result<ContractorReportResponseDto, String> {
246        let mut report = self
247            .repo
248            .find_by_id(id)
249            .await?
250            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
251
252        if report.organization_id != organization_id {
253            return Err("Accès refusé".to_string());
254        }
255        report.reject(dto.comments, rejected_by)?;
256        let saved = self.repo.update(&report).await?;
257        Ok(ContractorReportResponseDto::from(&saved))
258    }
259
260    /// Génère un magic link JWT 72h pour l'accès PWA corps de métier (B16-2)
261    pub async fn generate_magic_link(
262        &self,
263        report_id: Uuid,
264        organization_id: Uuid,
265        base_url: &str,
266    ) -> Result<MagicLinkResponseDto, String> {
267        let mut report = self
268            .repo
269            .find_by_id(report_id)
270            .await?
271            .ok_or_else(|| format!("Rapport {} introuvable", report_id))?;
272
273        if report.organization_id != organization_id {
274            return Err("Accès refusé".to_string());
275        }
276
277        // Génère un token sécurisé (UUID v4 = 122 bits d'entropie)
278        let raw_token = Uuid::new_v4().to_string();
279        // En production on hasherait avec SHA-256 ou bcrypt ; ici on stocke le raw
280        // (suffisant pour 72h, UUID non prédictible)
281        let token_hash = raw_token.clone();
282        let expires_at = Utc::now() + Duration::hours(MAGIC_LINK_VALIDITY_HOURS);
283
284        report.magic_token_hash = Some(token_hash);
285        report.magic_token_expires_at = Some(expires_at);
286        report.updated_at = Utc::now();
287
288        self.repo.update(&report).await?;
289
290        let magic_link = format!(
291            "{}/contractor/?token={}",
292            base_url.trim_end_matches('/'),
293            raw_token
294        );
295
296        Ok(MagicLinkResponseDto {
297            magic_link,
298            expires_at,
299        })
300    }
301
302    /// Supprime un rapport (Draft seulement)
303    pub async fn delete(&self, id: Uuid, organization_id: Uuid) -> Result<(), String> {
304        let report = self
305            .repo
306            .find_by_id(id)
307            .await?
308            .ok_or_else(|| format!("Rapport {} introuvable", id))?;
309
310        if report.organization_id != organization_id {
311            return Err("Accès refusé".to_string());
312        }
313        if report.status != ContractorReportStatus::Draft {
314            return Err(format!(
315                "Seuls les rapports en brouillon peuvent être supprimés (état actuel: {:?})",
316                report.status
317            ));
318        }
319        self.repo.delete(id).await?;
320        Ok(())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::application::ports::contractor_report_repository::ContractorReportRepository;
328    use async_trait::async_trait;
329    use mockall::mock;
330
331    mock! {
332        ContractorReportRepo {}
333
334        #[async_trait]
335        impl ContractorReportRepository for ContractorReportRepo {
336            async fn create(&self, report: &ContractorReport) -> Result<ContractorReport, String>;
337            async fn find_by_id(&self, id: Uuid) -> Result<Option<ContractorReport>, String>;
338            async fn find_by_magic_token(&self, token_hash: &str) -> Result<Option<ContractorReport>, String>;
339            async fn find_by_ticket(&self, ticket_id: Uuid) -> Result<Vec<ContractorReport>, String>;
340            async fn find_by_quote(&self, quote_id: Uuid) -> Result<Vec<ContractorReport>, String>;
341            async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<ContractorReport>, String>;
342            async fn find_by_organization(&self, organization_id: Uuid) -> Result<Vec<ContractorReport>, String>;
343            async fn update(&self, report: &ContractorReport) -> Result<ContractorReport, String>;
344            async fn delete(&self, id: Uuid) -> Result<bool, String>;
345        }
346    }
347
348    fn make_draft_report(org_id: Uuid) -> ContractorReport {
349        let mut r = ContractorReport::new(
350            org_id,
351            Uuid::new_v4(),
352            "Martin Plomberie SPRL".to_string(),
353            Some(Uuid::new_v4()),
354            None,
355            None,
356        )
357        .unwrap();
358        r.compte_rendu = Some("Travaux effectués conformément au devis".to_string());
359        r
360    }
361
362    #[tokio::test]
363    async fn test_create_report_success() {
364        let org_id = Uuid::new_v4();
365        let building_id = Uuid::new_v4();
366        let ticket_id = Uuid::new_v4();
367
368        let mut mock_repo = MockContractorReportRepo::new();
369        mock_repo.expect_create().returning(|r| Ok(r.clone()));
370
371        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
372
373        let dto = CreateContractorReportDto {
374            building_id,
375            contractor_name: "Plombier SA".to_string(),
376            ticket_id: Some(ticket_id),
377            quote_id: None,
378            contractor_user_id: None,
379        };
380
381        let result = uc.create(org_id, dto).await;
382        assert!(result.is_ok());
383        let resp = result.unwrap();
384        assert_eq!(resp.organization_id, org_id);
385        assert_eq!(resp.building_id, building_id);
386        assert_eq!(resp.contractor_name, "Plombier SA");
387        assert_eq!(resp.status, "draft");
388    }
389
390    #[tokio::test]
391    async fn test_get_by_token_success() {
392        let org_id = Uuid::new_v4();
393        let mut report = make_draft_report(org_id);
394        let token = "valid-token-hash";
395        report.magic_token_hash = Some(token.to_string());
396        report.magic_token_expires_at = Some(Utc::now() + Duration::hours(24));
397
398        let report_clone = report.clone();
399        let mut mock_repo = MockContractorReportRepo::new();
400        mock_repo
401            .expect_find_by_magic_token()
402            .withf(|t| t == "valid-token-hash")
403            .returning(move |_| Ok(Some(report_clone.clone())));
404
405        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
406
407        let result = uc.get_by_token(token).await;
408        assert!(result.is_ok());
409        let resp = result.unwrap();
410        assert_eq!(resp.contractor_name, "Martin Plomberie SPRL");
411    }
412
413    #[tokio::test]
414    async fn test_submit_report_success() {
415        let org_id = Uuid::new_v4();
416        let report = make_draft_report(org_id);
417        let report_id = report.id;
418
419        let report_for_find = report.clone();
420        let mut mock_repo = MockContractorReportRepo::new();
421        mock_repo
422            .expect_find_by_id()
423            .withf(move |id| *id == report_id)
424            .returning(move |_| Ok(Some(report_for_find.clone())));
425        mock_repo.expect_update().returning(|r| Ok(r.clone()));
426
427        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
428
429        let result = uc.submit(report_id, org_id).await;
430        assert!(result.is_ok());
431        let resp = result.unwrap();
432        assert_eq!(resp.status, "submitted");
433    }
434
435    #[tokio::test]
436    async fn test_start_review_via_update() {
437        // Test the review workflow by submitting then validating (which accepts Submitted state)
438        let org_id = Uuid::new_v4();
439        let mut report = make_draft_report(org_id);
440        // Pre-set to Submitted state to test review path
441        report.status = ContractorReportStatus::Submitted;
442        report.submitted_at = Some(Utc::now());
443        let report_id = report.id;
444
445        let report_for_find = report.clone();
446        let mut mock_repo = MockContractorReportRepo::new();
447        mock_repo
448            .expect_find_by_id()
449            .withf(move |id| *id == report_id)
450            .returning(move |_| Ok(Some(report_for_find.clone())));
451        mock_repo.expect_update().returning(|r| Ok(r.clone()));
452
453        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
454        let validator_id = Uuid::new_v4();
455
456        let result = uc.validate(report_id, org_id, validator_id).await;
457        assert!(result.is_ok());
458        let resp = result.unwrap();
459        assert_eq!(resp.status, "validated");
460        assert_eq!(resp.validated_by, Some(validator_id));
461    }
462
463    #[tokio::test]
464    async fn test_validate_report_success() {
465        let org_id = Uuid::new_v4();
466        let validator_id = Uuid::new_v4();
467        let mut report = make_draft_report(org_id);
468        report.status = ContractorReportStatus::UnderReview;
469        let report_id = report.id;
470
471        let report_for_find = report.clone();
472        let mut mock_repo = MockContractorReportRepo::new();
473        mock_repo
474            .expect_find_by_id()
475            .withf(move |id| *id == report_id)
476            .returning(move |_| Ok(Some(report_for_find.clone())));
477        mock_repo.expect_update().returning(|r| Ok(r.clone()));
478
479        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
480
481        let result = uc.validate(report_id, org_id, validator_id).await;
482        assert!(result.is_ok());
483        let resp = result.unwrap();
484        assert_eq!(resp.status, "validated");
485        assert!(resp.validated_at.is_some());
486        assert_eq!(resp.validated_by, Some(validator_id));
487    }
488
489    #[tokio::test]
490    async fn test_generate_magic_link_success() {
491        let org_id = Uuid::new_v4();
492        let report = make_draft_report(org_id);
493        let report_id = report.id;
494
495        let report_for_find = report.clone();
496        let mut mock_repo = MockContractorReportRepo::new();
497        mock_repo
498            .expect_find_by_id()
499            .withf(move |id| *id == report_id)
500            .returning(move |_| Ok(Some(report_for_find.clone())));
501        mock_repo.expect_update().returning(|r| Ok(r.clone()));
502
503        let uc = ContractorReportUseCases::new(Arc::new(mock_repo));
504
505        let result = uc
506            .generate_magic_link(report_id, org_id, "https://app.koprogo.be")
507            .await;
508        assert!(result.is_ok());
509        let link_dto = result.unwrap();
510        assert!(link_dto
511            .magic_link
512            .starts_with("https://app.koprogo.be/contractor/?token="));
513        assert!(link_dto.expires_at > Utc::now());
514    }
515}