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::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
11const 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 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 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 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 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 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 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 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 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 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 Ok(ContractorReportResponseDto::from(&saved))
215 }
216
217 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 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 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 let raw_token = Uuid::new_v4().to_string();
279 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 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 let org_id = Uuid::new_v4();
439 let mut report = make_draft_report(org_id);
440 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}