koprogo_api/infrastructure/web/handlers/
contractor_evaluation_handlers.rs1use 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#[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
111fn 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#[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#[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 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#[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#[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}