Skip to main content

koprogo_api/infrastructure/web/handlers/
contractor_evaluation_handlers.rs

1//! HTTP handlers for ContractorEvaluation (Story 3.9 — FR34 FR35 INV-21
2//! INV-24).
3//!
4//! Routes:
5//! - `POST /contractor-evaluations`                            — create (syndic/superadmin)
6//! - `GET  /contractor-evaluations/{id}`                       — fetch details
7//! - `GET  /contractors/{contractor_user_id}/evaluations`      — list for a contractor
8//!
9//! All routes are JWT-protected. `GET /contractor-evaluations/{id}` took
10//! `AuthenticatedUser` without using it (`_user`) and is now cloisonnée via
11//! `verify_technical_spec_org_access` (#882). `GET
12//! /contractors/{contractor_user_id}/evaluations` remains classified but
13//! unfixed — see the comment above `list_contractor_evaluations`: a product
14//! decision is needed first.
15
16use crate::application::error::AppError;
17use crate::domain::entities::{ContractorEvaluation, EvaluationScores};
18use crate::infrastructure::web::middleware::scope_guard::verify_technical_spec_org_access;
19use crate::infrastructure::web::{AppState, AuthenticatedUser};
20use actix_web::{get, post, web, HttpResponse};
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use uuid::Uuid;
24
25// ---------------------------------------------------------------------------
26// DTOs
27// ---------------------------------------------------------------------------
28
29#[derive(Debug, Deserialize, utoipa::ToSchema)]
30pub struct EvaluationScoresDto {
31    pub quality: u8,
32    pub timeliness: u8,
33    pub communication: u8,
34    pub cost_compliance: u8,
35    pub overall: u8,
36}
37
38impl From<EvaluationScoresDto> for EvaluationScores {
39    fn from(d: EvaluationScoresDto) -> Self {
40        Self {
41            quality: d.quality,
42            timeliness: d.timeliness,
43            communication: d.communication,
44            cost_compliance: d.cost_compliance,
45            overall: d.overall,
46        }
47    }
48}
49
50#[derive(Debug, Deserialize, utoipa::ToSchema)]
51pub struct CreateContractorEvaluationRequest {
52    pub contractor_user_id: Uuid,
53    pub technical_spec_id: Uuid,
54    #[serde(default)]
55    pub linked_ticket_ids: Vec<Uuid>,
56    pub scores: EvaluationScoresDto,
57    pub comment: String,
58}
59
60#[derive(Debug, Serialize, utoipa::ToSchema)]
61pub struct EvaluationScoresOutDto {
62    pub quality: u8,
63    pub timeliness: u8,
64    pub communication: u8,
65    pub cost_compliance: u8,
66    pub overall: u8,
67}
68
69impl From<EvaluationScores> for EvaluationScoresOutDto {
70    fn from(s: EvaluationScores) -> Self {
71        Self {
72            quality: s.quality,
73            timeliness: s.timeliness,
74            communication: s.communication,
75            cost_compliance: s.cost_compliance,
76            overall: s.overall,
77        }
78    }
79}
80
81#[derive(Debug, Serialize, utoipa::ToSchema)]
82pub struct ContractorEvaluationDto {
83    pub id: Uuid,
84    pub contractor_user_id: Uuid,
85    pub technical_spec_id: Uuid,
86    pub linked_ticket_ids: Vec<Uuid>,
87    pub evaluator_user_id: Uuid,
88    pub scores: EvaluationScoresOutDto,
89    pub average_score: f64,
90    pub comment: String,
91    pub created_at: DateTime<Utc>,
92}
93
94impl From<ContractorEvaluation> for ContractorEvaluationDto {
95    fn from(e: ContractorEvaluation) -> Self {
96        let average = e.average_score();
97        Self {
98            id: e.id,
99            contractor_user_id: e.contractor_user_id,
100            technical_spec_id: e.technical_spec_id,
101            linked_ticket_ids: e.linked_ticket_ids,
102            evaluator_user_id: e.evaluator_user_id,
103            scores: EvaluationScoresOutDto::from(e.scores),
104            average_score: average,
105            comment: e.comment,
106            created_at: e.created_at,
107        }
108    }
109}
110
111// ---------------------------------------------------------------------------
112// Guards
113// ---------------------------------------------------------------------------
114
115fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
116    match user.role.as_str() {
117        "syndic" | "superadmin" => Ok(()),
118        _ => Err(AppError::Forbidden(
119            "Only syndic or superadmin can record a ContractorEvaluation".to_string(),
120        )),
121    }
122}
123
124// ---------------------------------------------------------------------------
125// POST /contractor-evaluations
126// ---------------------------------------------------------------------------
127
128#[utoipa::path(
129    post,
130    path = "/contractor-evaluations",
131    tag = "ContractorEvaluation",
132    request_body = CreateContractorEvaluationRequest,
133    responses(
134        (status = 201, description = "Evaluation created", body = ContractorEvaluationDto),
135        (status = 400, description = "Validation error"),
136        (status = 403, description = "Forbidden — only syndic / superadmin"),
137        (status = 404, description = "TechnicalSpec not found"),
138        (status = 422, description = "TechnicalSpec not Approved, or self-evaluation"),
139    ),
140)]
141#[post("/contractor-evaluations")]
142pub async fn create_contractor_evaluation(
143    state: web::Data<AppState>,
144    user: AuthenticatedUser,
145    body: web::Json<CreateContractorEvaluationRequest>,
146) -> Result<HttpResponse, AppError> {
147    require_syndic_or_superadmin(&user)?;
148    let payload = body.into_inner();
149    let evaluation = state
150        .contractor_evaluation_use_cases
151        .create_evaluation(
152            payload.contractor_user_id,
153            payload.technical_spec_id,
154            payload.linked_ticket_ids,
155            user.user_id,
156            payload.scores.into(),
157            payload.comment,
158        )
159        .await?;
160    Ok(HttpResponse::Created().json(ContractorEvaluationDto::from(evaluation)))
161}
162
163// ---------------------------------------------------------------------------
164// GET /contractor-evaluations/{id}
165// ---------------------------------------------------------------------------
166
167#[utoipa::path(
168    get,
169    path = "/contractor-evaluations/{id}",
170    tag = "ContractorEvaluation",
171    responses(
172        (status = 200, description = "Evaluation details", body = ContractorEvaluationDto),
173        (status = 404, description = "Evaluation not found"),
174    ),
175)]
176#[get("/contractor-evaluations/{id}")]
177pub async fn get_contractor_evaluation(
178    state: web::Data<AppState>,
179    user: AuthenticatedUser,
180    path: web::Path<Uuid>,
181) -> Result<HttpResponse, AppError> {
182    let id = path.into_inner();
183    let evaluation = state
184        .contractor_evaluation_use_cases
185        .get_evaluation(id)
186        .await?;
187
188    // Cloisonnement (#882) : l'évaluation ne porte pas d'organisation
189    // directement, mais remonte à une fiche technique (`technical_spec_id`)
190    // qui, elle, relève d'une ACP précise. L'identité était prise et jetée
191    // (`_user`), et `comment`/`scores` sont le jugement porté sur un
192    // prestataire pour un chantier qui n'a pas à être lu hors de son ACP.
193    verify_technical_spec_org_access(
194        &user,
195        evaluation.technical_spec_id,
196        &state.technical_spec_use_cases,
197        &state.acp_use_cases,
198    )
199    .await?;
200
201    Ok(HttpResponse::Ok().json(ContractorEvaluationDto::from(evaluation)))
202}
203
204// ---------------------------------------------------------------------------
205// GET /contractors/{contractor_user_id}/evaluations
206// ---------------------------------------------------------------------------
207
208#[utoipa::path(
209    get,
210    path = "/contractors/{contractor_user_id}/evaluations",
211    tag = "ContractorEvaluation",
212    responses(
213        (status = 200, description = "List of evaluations (newest first)", body = Vec<ContractorEvaluationDto>),
214    ),
215)]
216// Cloisonnement (#882) : classée, non corrigée. L'identité est prise et
217// jetée (`_user`) délibérément pour l'instant — trancher entre deux lectures
218// opposées est un choix produit, pas un oubli :
219// - un historique de notation d'un prestataire peut être une donnée de
220//   RÉPUTATION, légitimement transverse aux ACP (un prestataire malhonnête
221//   chez A intéresse B) ;
222// - mais `comment` et `linked_ticket_ids` peuvent révéler des détails
223//   internes d'une ACP tierce (nature exacte d'un litige, ticket lié).
224// Tant que l'arbitrage n'est pas rendu, ce gestionnaire rend le détail BRUT
225// hors périmètre. Un DTO agrégé (moyenne, nombre d'avis) réglerait les deux
226// lectures à la fois, mais c'est un changement de contrat d'API.
227#[get("/contractors/{contractor_user_id}/evaluations")]
228pub async fn list_contractor_evaluations(
229    state: web::Data<AppState>,
230    _user: AuthenticatedUser,
231    path: web::Path<Uuid>,
232) -> Result<HttpResponse, AppError> {
233    let contractor_user_id = path.into_inner();
234    let evaluations = state
235        .contractor_evaluation_use_cases
236        .list_for_contractor(contractor_user_id)
237        .await?;
238    let dtos: Vec<ContractorEvaluationDto> = evaluations
239        .into_iter()
240        .map(ContractorEvaluationDto::from)
241        .collect();
242    Ok(HttpResponse::Ok().json(dtos))
243}