Skip to main content

koprogo_api/domain/economie_circulaire/
contractor_evaluation.rs

1//! ContractorEvaluation — append-only rating of a contractor's prestation
2//! on an approved `TechnicalSpec` (Story 3.9 — FR34 FR35 INV-21 INV-24).
3//!
4//! Distinct from `ContractEvaluation` (module retiré depuis ; la distinction
5//! reste utile, le lien ne pointait plus nulle part),
6//! which is the legacy marketplace-rating entity (Issue #276). The two live
7//! side-by-side intentionally: Story 3.9 only introduces the new audit-grade
8//! flow gated by a signed TechnicalSpec; the legacy free-form rating remains
9//! for the marketplace use-case.
10//!
11//! # Workflow
12//!
13//! 1. A syndic (or a mandated owner) opens an evaluation against a
14//!    contractor user once a `TechnicalSpec` has reached
15//!    [`TechnicalSpecStatus::Approved`](super::technical_spec::TechnicalSpecStatus::Approved).
16//! 2. The evaluator fills the 5 scores ([`EvaluationScores`]) and writes a
17//!    comment.
18//! 3. The row is persisted append-only — there are no public setters and a
19//!    DB trigger blocks UPDATE / DELETE.
20//!
21//! # Invariants enforced at `new()` time
22//!
23//! - every score MUST be in `[1, 5]` (matches the SMALLINT `CHECK` in the
24//!   migration);
25//! - `linked_ticket_ids.len()` ≤ [`MAX_LINKED_TICKETS`], no duplicates;
26//! - `comment.len()` ∈ `[MIN_COMMENT_LEN, MAX_COMMENT_LEN]` (after trim);
27//! - `evaluator_user_id != contractor_user_id` (no self-evaluation —
28//!   INV-21);
29//! - no nil UUIDs (defensive — surface a 400 instead of an FK error at
30//!   persistence time).
31//!
32//! Note: the *additional* invariant "the referenced TechnicalSpec MUST be
33//! in `Approved` status" lives at the use-case boundary
34//! ([`crate::application::use_cases::contractor_evaluation_use_cases`]) —
35//! it is a workflow guard, not a structural one.
36
37use crate::application::error::AppError;
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use std::collections::HashSet;
41use uuid::Uuid;
42
43// ============================================================================
44// Bound constants
45// ============================================================================
46
47pub const MIN_SCORE: u8 = 1;
48pub const MAX_SCORE: u8 = 5;
49pub const MIN_COMMENT_LEN: usize = 10;
50pub const MAX_COMMENT_LEN: usize = 2000;
51pub const MAX_LINKED_TICKETS: usize = 20;
52
53// ============================================================================
54// EvaluationScores
55// ============================================================================
56
57/// 5 dimensions rated on a 1..=5 Likert scale. The "overall" dimension is
58/// stored explicitly (not computed) because the evaluator may weigh the
59/// dimensions differently in their head — we don't want to surface a stale
60/// arithmetic average as the headline number.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub struct EvaluationScores {
63    /// Workmanship quality (1 = bad, 5 = excellent).
64    pub quality: u8,
65    /// Did they hit the agreed timeline?
66    pub timeliness: u8,
67    /// Were they reachable / responsive?
68    pub communication: u8,
69    /// Did the final invoice match the original quote?
70    pub cost_compliance: u8,
71    /// Headline opinion — what the evaluator would tell a peer.
72    pub overall: u8,
73}
74
75impl EvaluationScores {
76    /// True iff every dimension lies in `[MIN_SCORE, MAX_SCORE]`.
77    fn is_within_bounds(&self) -> bool {
78        let in_bounds = |s: u8| s >= MIN_SCORE && s <= MAX_SCORE;
79        in_bounds(self.quality)
80            && in_bounds(self.timeliness)
81            && in_bounds(self.communication)
82            && in_bounds(self.cost_compliance)
83            && in_bounds(self.overall)
84    }
85
86    /// Arithmetic mean across the 5 dimensions. Useful for ranking but not
87    /// authoritative — the evaluator's `overall` is the headline.
88    pub fn average(&self) -> f64 {
89        let sum = self.quality as u32
90            + self.timeliness as u32
91            + self.communication as u32
92            + self.cost_compliance as u32
93            + self.overall as u32;
94        sum as f64 / 5.0
95    }
96}
97
98// ============================================================================
99// ContractorEvaluation entity
100// ============================================================================
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct ContractorEvaluation {
104    pub id: Uuid,
105    /// User id of the contractor being evaluated (role = Contractor).
106    pub contractor_user_id: Uuid,
107    /// REQUIRED — gating signed TechnicalSpec (Story 3.8). The application
108    /// layer additionally checks the spec is in status `Approved`.
109    pub technical_spec_id: Uuid,
110    /// Tickets that motivated the evaluation (works carried out, complaints
111    /// handled, etc.). Bounded to [`MAX_LINKED_TICKETS`] entries, no
112    /// duplicates.
113    pub linked_ticket_ids: Vec<Uuid>,
114    /// Syndic or mandated owner who signed off the evaluation.
115    pub evaluator_user_id: Uuid,
116    pub scores: EvaluationScores,
117    /// Free-form justification (`[10, 2000]` chars). Audit-grade — the
118    /// contractor can request access to it via the GDPR data export
119    /// endpoints.
120    pub comment: String,
121    pub created_at: DateTime<Utc>,
122}
123
124impl ContractorEvaluation {
125    /// Build a new ContractorEvaluation. Enforces every structural invariant
126    /// (cf. module-level docs). Workflow invariants (TechnicalSpec must be
127    /// Approved, evaluator must hold a valid role) live in the use-case.
128    pub fn new(
129        contractor_user_id: Uuid,
130        technical_spec_id: Uuid,
131        linked_ticket_ids: Vec<Uuid>,
132        evaluator_user_id: Uuid,
133        scores: EvaluationScores,
134        comment: String,
135    ) -> Result<Self, AppError> {
136        let trimmed_comment = comment.trim().to_string();
137        Self::validate_invariants(
138            contractor_user_id,
139            technical_spec_id,
140            &linked_ticket_ids,
141            evaluator_user_id,
142            &scores,
143            &trimmed_comment,
144        )?;
145
146        Ok(Self {
147            id: Uuid::new_v4(),
148            contractor_user_id,
149            technical_spec_id,
150            linked_ticket_ids,
151            evaluator_user_id,
152            scores,
153            comment: trimmed_comment,
154            created_at: Utc::now(),
155        })
156    }
157
158    fn validate_invariants(
159        contractor_user_id: Uuid,
160        technical_spec_id: Uuid,
161        linked_ticket_ids: &[Uuid],
162        evaluator_user_id: Uuid,
163        scores: &EvaluationScores,
164        comment: &str,
165    ) -> Result<(), AppError> {
166        // 1. Nil UUIDs — defensive guard.
167        if contractor_user_id.is_nil() || technical_spec_id.is_nil() || evaluator_user_id.is_nil() {
168            return Err(AppError::Validation(
169                "ContractorEvaluation references must not be nil UUIDs".to_string(),
170            ));
171        }
172        // 2. Self-evaluation — INV-21. Typed variant (not generic Validation)
173        //    so handlers can surface a precise 422 + i18n message.
174        if evaluator_user_id == contractor_user_id {
175            return Err(AppError::EvaluatorIsContractor);
176        }
177        // 3. Scores within [1, 5].
178        if !scores.is_within_bounds() {
179            return Err(AppError::Validation(format!(
180                "ContractorEvaluation scores must be in [{}, {}] for every dimension",
181                MIN_SCORE, MAX_SCORE
182            )));
183        }
184        // 4. linked_ticket_ids bound + uniqueness.
185        if linked_ticket_ids.len() > MAX_LINKED_TICKETS {
186            return Err(AppError::Validation(format!(
187                "linked_ticket_ids must contain at most {} entries (got {})",
188                MAX_LINKED_TICKETS,
189                linked_ticket_ids.len()
190            )));
191        }
192        let mut seen: HashSet<Uuid> = HashSet::with_capacity(linked_ticket_ids.len());
193        for id in linked_ticket_ids {
194            if id.is_nil() {
195                return Err(AppError::Validation(
196                    "linked_ticket_ids must not contain nil UUIDs".to_string(),
197                ));
198            }
199            if !seen.insert(*id) {
200                return Err(AppError::Validation(
201                    "linked_ticket_ids must not contain duplicates".to_string(),
202                ));
203            }
204        }
205        // 5. Comment length (counted in chars, not bytes — matches the DB
206        //    CHECK using length() which counts characters).
207        let c_len = comment.chars().count();
208        if c_len < MIN_COMMENT_LEN || c_len > MAX_COMMENT_LEN {
209            return Err(AppError::Validation(format!(
210                "comment length must be in [{}, {}] (got {})",
211                MIN_COMMENT_LEN, MAX_COMMENT_LEN, c_len
212            )));
213        }
214        Ok(())
215    }
216
217    /// Convenience: arithmetic mean of the 5 dimensions. Delegates to
218    /// [`EvaluationScores::average`].
219    pub fn average_score(&self) -> f64 {
220        self.scores.average()
221    }
222}
223
224// ============================================================================
225// Tests — taxonomie 4 catégories obligatoire (CRITICAL.md #3, Story 3.9)
226// ============================================================================
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn fixture_pair() -> (Uuid, Uuid) {
233        (Uuid::new_v4(), Uuid::new_v4())
234    }
235
236    fn fixture_scores_all_top() -> EvaluationScores {
237        EvaluationScores {
238            quality: 5,
239            timeliness: 5,
240            communication: 5,
241            cost_compliance: 5,
242            overall: 5,
243        }
244    }
245
246    fn fixture_scores_all_bottom() -> EvaluationScores {
247        EvaluationScores {
248            quality: 1,
249            timeliness: 1,
250            communication: 1,
251            cost_compliance: 1,
252            overall: 1,
253        }
254    }
255
256    fn fixture_comment_min() -> String {
257        // exactly MIN_COMMENT_LEN chars
258        "X".repeat(MIN_COMMENT_LEN)
259    }
260
261    fn build_ok(scores: EvaluationScores, comment: String) -> ContractorEvaluation {
262        let (contractor, evaluator) = fixture_pair();
263        ContractorEvaluation::new(
264            contractor,
265            Uuid::new_v4(),
266            Vec::new(),
267            evaluator,
268            scores,
269            comment,
270        )
271        .expect("valid ContractorEvaluation must be created")
272    }
273
274    // ---- @happy -------------------------------------------------------------
275
276    #[test]
277    fn happy_minimal_evaluation_all_top_scores() {
278        let e = build_ok(fixture_scores_all_top(), fixture_comment_min());
279        assert_eq!(e.scores.quality, 5);
280        assert_eq!(e.average_score(), 5.0);
281        assert!(e.linked_ticket_ids.is_empty());
282    }
283
284    #[test]
285    fn happy_all_bottom_scores_accepted() {
286        let e = build_ok(fixture_scores_all_bottom(), fixture_comment_min());
287        assert_eq!(e.average_score(), 1.0);
288    }
289
290    #[test]
291    fn happy_average_score_mid_value() {
292        // (3+3+3+3+3)/5 = 3.0
293        let scores = EvaluationScores {
294            quality: 3,
295            timeliness: 3,
296            communication: 3,
297            cost_compliance: 3,
298            overall: 3,
299        };
300        let e = build_ok(scores, fixture_comment_min());
301        assert_eq!(e.average_score(), 3.0);
302    }
303
304    #[test]
305    fn happy_evaluator_differs_from_contractor_persisted() {
306        let e = build_ok(fixture_scores_all_top(), fixture_comment_min());
307        assert_ne!(e.evaluator_user_id, e.contractor_user_id);
308    }
309
310    #[test]
311    fn happy_comment_is_trimmed_before_storage() {
312        let (contractor, evaluator) = fixture_pair();
313        let e = ContractorEvaluation::new(
314            contractor,
315            Uuid::new_v4(),
316            Vec::new(),
317            evaluator,
318            fixture_scores_all_top(),
319            format!("  {}  ", fixture_comment_min()),
320        )
321        .unwrap();
322        assert!(!e.comment.starts_with(' '));
323        assert!(!e.comment.ends_with(' '));
324    }
325
326    // ---- @edge --------------------------------------------------------------
327
328    #[test]
329    fn edge_exactly_max_linked_tickets_accepted() {
330        let (contractor, evaluator) = fixture_pair();
331        let tickets: Vec<Uuid> = (0..MAX_LINKED_TICKETS).map(|_| Uuid::new_v4()).collect();
332        let res = ContractorEvaluation::new(
333            contractor,
334            Uuid::new_v4(),
335            tickets,
336            evaluator,
337            fixture_scores_all_top(),
338            fixture_comment_min(),
339        );
340        assert!(res.is_ok(), "exactly MAX_LINKED_TICKETS must succeed");
341    }
342
343    #[test]
344    fn edge_one_over_max_linked_tickets_rejected() {
345        let (contractor, evaluator) = fixture_pair();
346        let tickets: Vec<Uuid> = (0..=MAX_LINKED_TICKETS).map(|_| Uuid::new_v4()).collect();
347        let err = ContractorEvaluation::new(
348            contractor,
349            Uuid::new_v4(),
350            tickets,
351            evaluator,
352            fixture_scores_all_top(),
353            fixture_comment_min(),
354        )
355        .unwrap_err();
356        assert!(matches!(err, AppError::Validation(_)));
357    }
358
359    #[test]
360    fn edge_comment_exactly_min_len_accepted() {
361        let (contractor, evaluator) = fixture_pair();
362        let res = ContractorEvaluation::new(
363            contractor,
364            Uuid::new_v4(),
365            Vec::new(),
366            evaluator,
367            fixture_scores_all_top(),
368            "X".repeat(MIN_COMMENT_LEN),
369        );
370        assert!(res.is_ok());
371    }
372
373    #[test]
374    fn edge_comment_one_under_min_rejected() {
375        let (contractor, evaluator) = fixture_pair();
376        let err = ContractorEvaluation::new(
377            contractor,
378            Uuid::new_v4(),
379            Vec::new(),
380            evaluator,
381            fixture_scores_all_top(),
382            "X".repeat(MIN_COMMENT_LEN - 1),
383        )
384        .unwrap_err();
385        assert!(matches!(err, AppError::Validation(_)));
386    }
387
388    #[test]
389    fn edge_comment_exactly_max_len_accepted() {
390        let (contractor, evaluator) = fixture_pair();
391        let res = ContractorEvaluation::new(
392            contractor,
393            Uuid::new_v4(),
394            Vec::new(),
395            evaluator,
396            fixture_scores_all_top(),
397            "X".repeat(MAX_COMMENT_LEN),
398        );
399        assert!(res.is_ok());
400    }
401
402    // ---- @security ----------------------------------------------------------
403
404    #[test]
405    fn security_evaluator_equals_contractor_returns_typed_error() {
406        let same = Uuid::new_v4();
407        let err = ContractorEvaluation::new(
408            same,
409            Uuid::new_v4(),
410            Vec::new(),
411            same,
412            fixture_scores_all_top(),
413            fixture_comment_min(),
414        )
415        .unwrap_err();
416        assert!(matches!(err, AppError::EvaluatorIsContractor));
417    }
418
419    #[test]
420    fn security_duplicate_linked_tickets_rejected() {
421        let (contractor, evaluator) = fixture_pair();
422        let t = Uuid::new_v4();
423        let err = ContractorEvaluation::new(
424            contractor,
425            Uuid::new_v4(),
426            vec![t, t],
427            evaluator,
428            fixture_scores_all_top(),
429            fixture_comment_min(),
430        )
431        .unwrap_err();
432        assert!(matches!(err, AppError::Validation(_)));
433    }
434
435    #[test]
436    fn security_nil_uuids_rejected() {
437        let evaluator = Uuid::new_v4();
438        // nil contractor
439        let err1 = ContractorEvaluation::new(
440            Uuid::nil(),
441            Uuid::new_v4(),
442            Vec::new(),
443            evaluator,
444            fixture_scores_all_top(),
445            fixture_comment_min(),
446        )
447        .unwrap_err();
448        assert!(matches!(err1, AppError::Validation(_)));
449        // nil tech_spec
450        let err2 = ContractorEvaluation::new(
451            Uuid::new_v4(),
452            Uuid::nil(),
453            Vec::new(),
454            evaluator,
455            fixture_scores_all_top(),
456            fixture_comment_min(),
457        )
458        .unwrap_err();
459        assert!(matches!(err2, AppError::Validation(_)));
460        // nil evaluator
461        let err3 = ContractorEvaluation::new(
462            Uuid::new_v4(),
463            Uuid::new_v4(),
464            Vec::new(),
465            Uuid::nil(),
466            fixture_scores_all_top(),
467            fixture_comment_min(),
468        )
469        .unwrap_err();
470        assert!(matches!(err3, AppError::Validation(_)));
471    }
472
473    #[test]
474    fn security_nil_linked_ticket_id_rejected() {
475        let (contractor, evaluator) = fixture_pair();
476        let err = ContractorEvaluation::new(
477            contractor,
478            Uuid::new_v4(),
479            vec![Uuid::nil()],
480            evaluator,
481            fixture_scores_all_top(),
482            fixture_comment_min(),
483        )
484        .unwrap_err();
485        assert!(matches!(err, AppError::Validation(_)));
486    }
487
488    // ---- @negative ----------------------------------------------------------
489
490    #[test]
491    fn negative_score_zero_rejected() {
492        let (contractor, evaluator) = fixture_pair();
493        let scores = EvaluationScores {
494            quality: 0,
495            timeliness: 5,
496            communication: 5,
497            cost_compliance: 5,
498            overall: 5,
499        };
500        let err = ContractorEvaluation::new(
501            contractor,
502            Uuid::new_v4(),
503            Vec::new(),
504            evaluator,
505            scores,
506            fixture_comment_min(),
507        )
508        .unwrap_err();
509        assert!(matches!(err, AppError::Validation(_)));
510    }
511
512    #[test]
513    fn negative_score_six_rejected() {
514        let (contractor, evaluator) = fixture_pair();
515        let scores = EvaluationScores {
516            quality: 5,
517            timeliness: 5,
518            communication: 5,
519            cost_compliance: 5,
520            overall: 6,
521        };
522        let err = ContractorEvaluation::new(
523            contractor,
524            Uuid::new_v4(),
525            Vec::new(),
526            evaluator,
527            scores,
528            fixture_comment_min(),
529        )
530        .unwrap_err();
531        assert!(matches!(err, AppError::Validation(_)));
532    }
533
534    #[test]
535    fn negative_comment_too_long_rejected() {
536        let (contractor, evaluator) = fixture_pair();
537        let err = ContractorEvaluation::new(
538            contractor,
539            Uuid::new_v4(),
540            Vec::new(),
541            evaluator,
542            fixture_scores_all_top(),
543            "X".repeat(MAX_COMMENT_LEN + 1),
544        )
545        .unwrap_err();
546        assert!(matches!(err, AppError::Validation(_)));
547    }
548
549    #[test]
550    fn negative_empty_comment_rejected() {
551        let (contractor, evaluator) = fixture_pair();
552        let err = ContractorEvaluation::new(
553            contractor,
554            Uuid::new_v4(),
555            Vec::new(),
556            evaluator,
557            fixture_scores_all_top(),
558            String::new(),
559        )
560        .unwrap_err();
561        assert!(matches!(err, AppError::Validation(_)));
562    }
563
564    #[test]
565    fn negative_whitespace_only_comment_rejected() {
566        let (contractor, evaluator) = fixture_pair();
567        let err = ContractorEvaluation::new(
568            contractor,
569            Uuid::new_v4(),
570            Vec::new(),
571            evaluator,
572            fixture_scores_all_top(),
573            "          ".to_string(), // 10 spaces, trimmed to 0
574        )
575        .unwrap_err();
576        assert!(matches!(err, AppError::Validation(_)));
577    }
578}