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