koprogo_api/application/use_cases/
contractor_report_use_cases.rs1use 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
15const 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let raw_token = Uuid::new_v4().to_string();
336 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 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 let org_id = Uuid::new_v4();
496 let mut report = make_draft_report(org_id);
497 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}