Skip to main content

koprogo_api/infrastructure/web/handlers/
technical_spec_handlers.rs

1//! HTTP handlers for TechnicalSpec (Story 3.8 — FR33).
2//!
3//! Routes:
4//! - `POST /technical-specs`              — create a Draft spec (syndic/superadmin)
5//! - `POST /technical-specs/{id}/bump`    — create a new version (syndic/superadmin)
6//! - `POST /technical-specs/{id}/submit`  — Draft -> PendingSignatures (syndic/superadmin)
7//! - `POST /technical-specs/{id}/signatures` — record a signature (signatory)
8//! - `GET  /technical-specs/{id}`         — spec details
9//! - `GET  /technical-specs?acp_id={uuid}` — list specs for an ACP
10//!
11//! All routes are JWT-protected. The two GET routes above took
12//! `AuthenticatedUser` without using it (`_user`) — the Phase B scope
13//! tightening promised in the Story 3.8 acceptance notes — and are now
14//! cloisonnées like `sign_technical_spec` (#882).
15
16use crate::application::error::AppError;
17use crate::domain::entities::{
18    SemVer, SignatoryRole, TechnicalSpec, TechnicalSpecSignature, TechnicalSpecStatus,
19};
20use crate::infrastructure::web::middleware::scope_guard::{
21    verify_acp_org_access, verify_technical_spec_org_access,
22};
23use crate::infrastructure::web::{AppState, AuthenticatedUser};
24use actix_web::{get, post, web, HttpResponse};
25use chrono::{DateTime, Utc};
26use serde::{Deserialize, Serialize};
27use std::str::FromStr;
28use uuid::Uuid;
29
30// ---------------------------------------------------------------------------
31// DTOs
32// ---------------------------------------------------------------------------
33
34#[derive(Debug, Deserialize, utoipa::ToSchema)]
35pub struct CreateTechnicalSpecRequest {
36    pub acp_id: Uuid,
37    #[serde(default)]
38    pub building_id: Option<Uuid>,
39    pub title: String,
40    pub description: String,
41    /// SemVer string (`major.minor.patch`, e.g. `1.0.0`). Strict — no
42    /// `v`-prefix, no pre-release / build metadata.
43    pub version: String,
44    pub deliverables: Vec<String>,
45    /// Roles required to sign: `syndic`, `amo`, `lawyer`, `architect`,
46    /// `acp_representative`.
47    pub required_signatures: Vec<String>,
48    #[serde(default)]
49    pub attachments: Vec<String>,
50}
51
52#[derive(Debug, Deserialize, utoipa::ToSchema)]
53pub struct BumpTechnicalSpecRequest {
54    /// New SemVer. Must be strictly greater than the previous one.
55    pub version: String,
56    #[serde(default)]
57    pub title: Option<String>,
58    #[serde(default)]
59    pub description: Option<String>,
60    #[serde(default)]
61    pub deliverables: Option<Vec<String>>,
62    #[serde(default)]
63    pub required_signatures: Option<Vec<String>>,
64    #[serde(default)]
65    pub attachments: Option<Vec<String>>,
66}
67
68#[derive(Debug, Deserialize, utoipa::ToSchema)]
69pub struct SignTechnicalSpecRequest {
70    /// Role under which the caller signs. Must be in the spec's
71    /// `required_signatures` list.
72    pub role: String,
73    /// Optional Mandate id. REQUIRED for mandataire roles (amo / lawyer /
74    /// architect) — Story 3.4 chain.
75    #[serde(default)]
76    pub mandate_id: Option<Uuid>,
77}
78
79#[derive(Debug, Deserialize, utoipa::ToSchema)]
80pub struct ListTechnicalSpecsQuery {
81    /// Restrict the listing to the given ACP.
82    pub acp_id: Uuid,
83}
84
85#[derive(Debug, Serialize, utoipa::ToSchema)]
86pub struct TechnicalSpecDto {
87    pub id: Uuid,
88    pub acp_id: Uuid,
89    pub building_id: Option<Uuid>,
90    pub title: String,
91    pub description: String,
92    pub version: String,
93    pub status: String,
94    pub deliverables: Vec<String>,
95    pub required_signatures: Vec<String>,
96    pub attachments: Vec<String>,
97    pub previous_version_id: Option<Uuid>,
98    pub created_by: Uuid,
99    pub created_at: DateTime<Utc>,
100    pub updated_at: DateTime<Utc>,
101}
102
103impl From<TechnicalSpec> for TechnicalSpecDto {
104    fn from(s: TechnicalSpec) -> Self {
105        Self {
106            id: s.id,
107            acp_id: s.acp_id,
108            building_id: s.building_id,
109            title: s.title,
110            description: s.description,
111            version: s.version.to_string(),
112            status: s.status.to_string(),
113            deliverables: s.deliverables,
114            required_signatures: s
115                .required_signatures
116                .iter()
117                .map(|r| r.to_string())
118                .collect(),
119            attachments: s.attachments,
120            previous_version_id: s.previous_version_id,
121            created_by: s.created_by,
122            created_at: s.created_at,
123            updated_at: s.updated_at,
124        }
125    }
126}
127
128#[derive(Debug, Serialize, utoipa::ToSchema)]
129pub struct TechnicalSpecSignatureDto {
130    pub id: Uuid,
131    pub technical_spec_id: Uuid,
132    pub signatory_user_id: Uuid,
133    pub role: String,
134    pub mandate_id: Option<Uuid>,
135    pub signed_at: DateTime<Utc>,
136}
137
138impl From<TechnicalSpecSignature> for TechnicalSpecSignatureDto {
139    fn from(s: TechnicalSpecSignature) -> Self {
140        Self {
141            id: s.id,
142            technical_spec_id: s.technical_spec_id,
143            signatory_user_id: s.signatory_user_id,
144            role: s.role.to_string(),
145            mandate_id: s.mandate_id,
146            signed_at: s.signed_at,
147        }
148    }
149}
150
151// ---------------------------------------------------------------------------
152// Guards
153// ---------------------------------------------------------------------------
154
155fn require_syndic_or_superadmin(user: &AuthenticatedUser) -> Result<(), AppError> {
156    match user.role.as_str() {
157        "syndic" | "superadmin" => Ok(()),
158        _ => Err(AppError::Forbidden(
159            "Only syndic or superadmin can manage a TechnicalSpec".to_string(),
160        )),
161    }
162}
163
164fn parse_required(strs: &[String]) -> Result<Vec<SignatoryRole>, AppError> {
165    strs.iter().map(|s| SignatoryRole::from_str(s)).collect()
166}
167
168// ---------------------------------------------------------------------------
169// POST /technical-specs
170// ---------------------------------------------------------------------------
171
172#[utoipa::path(
173    post,
174    path = "/technical-specs",
175    tag = "TechnicalSpec",
176    request_body = CreateTechnicalSpecRequest,
177    responses(
178        (status = 201, description = "Spec created (Draft)", body = TechnicalSpecDto),
179        (status = 400, description = "Validation error"),
180        (status = 403, description = "Forbidden — only syndic / superadmin"),
181    ),
182)]
183#[post("/technical-specs")]
184pub async fn create_technical_spec(
185    state: web::Data<AppState>,
186    user: AuthenticatedUser,
187    body: web::Json<CreateTechnicalSpecRequest>,
188) -> Result<HttpResponse, AppError> {
189    require_syndic_or_superadmin(&user)?;
190    let payload = body.into_inner();
191    let version = SemVer::from_str(&payload.version)?;
192    let required = parse_required(&payload.required_signatures)?;
193
194    let spec = state
195        .technical_spec_use_cases
196        .create_spec(
197            payload.acp_id,
198            payload.building_id,
199            payload.title,
200            payload.description,
201            version,
202            payload.deliverables,
203            required,
204            payload.attachments,
205            user.user_id,
206        )
207        .await?;
208    Ok(HttpResponse::Created().json(TechnicalSpecDto::from(spec)))
209}
210
211// ---------------------------------------------------------------------------
212// POST /technical-specs/{id}/bump
213// ---------------------------------------------------------------------------
214
215#[utoipa::path(
216    post,
217    path = "/technical-specs/{id}/bump",
218    tag = "TechnicalSpec",
219    request_body = BumpTechnicalSpecRequest,
220    responses(
221        (status = 201, description = "New version created (Draft)", body = TechnicalSpecDto),
222        (status = 400, description = "Validation error"),
223        (status = 403, description = "Forbidden — only syndic / superadmin"),
224        (status = 404, description = "Previous spec not found"),
225    ),
226)]
227#[post("/technical-specs/{id}/bump")]
228pub async fn bump_technical_spec(
229    state: web::Data<AppState>,
230    user: AuthenticatedUser,
231    path: web::Path<Uuid>,
232    body: web::Json<BumpTechnicalSpecRequest>,
233) -> Result<HttpResponse, AppError> {
234    require_syndic_or_superadmin(&user)?;
235    let prev_id = path.into_inner();
236
237    // Cloisonnement : cette fiche doit relever d'une ACP que cet utilisateur a
238    // le droit de voir. `require_syndic_or_superadmin` vérifie le RÔLE et rien
239    // d'autre — un syndic de l'organisation A y passait pour agir sur la fiche
240    // technique de l'organisation B (#772).
241    //
242    // Le périmètre est l'ACP et non l'immeuble : `TechnicalSpec.building_id`
243    // est optionnel, `acp_id` ne l'est pas.
244    verify_technical_spec_org_access(
245        &user,
246        prev_id,
247        &state.technical_spec_use_cases,
248        &state.acp_use_cases,
249    )
250    .await?;
251    let payload = body.into_inner();
252    let new_version = SemVer::from_str(&payload.version)?;
253    let new_required = match payload.required_signatures {
254        Some(strs) => Some(parse_required(&strs)?),
255        None => None,
256    };
257
258    let spec = state
259        .technical_spec_use_cases
260        .bump_version(
261            prev_id,
262            new_version,
263            payload.title,
264            payload.description,
265            payload.deliverables,
266            new_required,
267            payload.attachments,
268        )
269        .await?;
270    Ok(HttpResponse::Created().json(TechnicalSpecDto::from(spec)))
271}
272
273// ---------------------------------------------------------------------------
274// POST /technical-specs/{id}/submit
275// ---------------------------------------------------------------------------
276
277#[utoipa::path(
278    post,
279    path = "/technical-specs/{id}/submit",
280    tag = "TechnicalSpec",
281    responses(
282        (status = 200, description = "Spec submitted (PendingSignatures)", body = TechnicalSpecDto),
283        (status = 403, description = "Forbidden — only syndic / superadmin"),
284        (status = 404, description = "Spec not found"),
285        (status = 409, description = "Spec already approved"),
286    ),
287)]
288#[post("/technical-specs/{id}/submit")]
289pub async fn submit_technical_spec(
290    state: web::Data<AppState>,
291    user: AuthenticatedUser,
292    path: web::Path<Uuid>,
293) -> Result<HttpResponse, AppError> {
294    require_syndic_or_superadmin(&user)?;
295    let id = path.into_inner();
296
297    // Cloisonnement : cette fiche doit relever d'une ACP que cet utilisateur a
298    // le droit de voir. `require_syndic_or_superadmin` ne vérifie que le RÔLE
299    // (#772). Périmètre = ACP, `building_id` étant optionnel sur l'entité.
300    verify_technical_spec_org_access(
301        &user,
302        id,
303        &state.technical_spec_use_cases,
304        &state.acp_use_cases,
305    )
306    .await?;
307
308    let spec = state
309        .technical_spec_use_cases
310        .submit_for_signatures(id)
311        .await?;
312    Ok(HttpResponse::Ok().json(TechnicalSpecDto::from(spec)))
313}
314
315// ---------------------------------------------------------------------------
316// POST /technical-specs/{id}/signatures
317// ---------------------------------------------------------------------------
318
319#[utoipa::path(
320    post,
321    path = "/technical-specs/{id}/signatures",
322    tag = "TechnicalSpec",
323    request_body = SignTechnicalSpecRequest,
324    responses(
325        (status = 201, description = "Signature recorded", body = TechnicalSpecSignatureDto),
326        (status = 400, description = "Spec not in PendingSignatures state"),
327        (status = 403, description = "Signatory role not authorised"),
328        (status = 404, description = "Spec not found"),
329        (status = 409, description = "Signature already exists for (user, role)"),
330    ),
331)]
332#[post("/technical-specs/{id}/signatures")]
333pub async fn sign_technical_spec(
334    state: web::Data<AppState>,
335    user: AuthenticatedUser,
336    path: web::Path<Uuid>,
337    body: web::Json<SignTechnicalSpecRequest>,
338) -> Result<HttpResponse, AppError> {
339    let spec_id = path.into_inner();
340
341    // Cloisonnement : cette fiche doit relever d'une ACP que cet utilisateur a
342    // le droit de voir. `require_syndic_or_superadmin` vérifie le RÔLE et rien
343    // d'autre — un syndic de l'organisation A y passait pour agir sur la fiche
344    // technique de l'organisation B (#772).
345    //
346    // Le périmètre est l'ACP et non l'immeuble : `TechnicalSpec.building_id`
347    // est optionnel, `acp_id` ne l'est pas.
348    verify_technical_spec_org_access(
349        &user,
350        spec_id,
351        &state.technical_spec_use_cases,
352        &state.acp_use_cases,
353    )
354    .await?;
355
356    // Cloisonnement : cette fiche doit relever d'une ACP que cet utilisateur a
357    // le droit de voir. `require_syndic_or_superadmin` vérifie le RÔLE et rien
358    // d'autre — un syndic de l'organisation A y passait pour agir sur la fiche
359    // technique de l'organisation B (#772).
360    //
361    // Le périmètre est l'ACP et non l'immeuble : `TechnicalSpec.building_id`
362    // est optionnel, `acp_id` ne l'est pas.
363    verify_technical_spec_org_access(
364        &user,
365        spec_id,
366        &state.technical_spec_use_cases,
367        &state.acp_use_cases,
368    )
369    .await?;
370    let payload = body.into_inner();
371    let role = SignatoryRole::from_str(&payload.role)?;
372
373    let sig = state
374        .technical_spec_use_cases
375        .sign_spec(spec_id, user.user_id, role, payload.mandate_id)
376        .await?;
377    Ok(HttpResponse::Created().json(TechnicalSpecSignatureDto::from(sig)))
378}
379
380// ---------------------------------------------------------------------------
381// GET /technical-specs/{id}
382// ---------------------------------------------------------------------------
383
384#[utoipa::path(
385    get,
386    path = "/technical-specs/{id}",
387    tag = "TechnicalSpec",
388    responses(
389        (status = 200, description = "Spec details", body = TechnicalSpecDto),
390        (status = 404, description = "Spec not found"),
391    ),
392)]
393#[get("/technical-specs/{id}")]
394pub async fn get_technical_spec(
395    state: web::Data<AppState>,
396    user: AuthenticatedUser,
397    path: web::Path<Uuid>,
398) -> Result<HttpResponse, AppError> {
399    let id = path.into_inner();
400
401    // Cloisonnement (#882) : l'identité était prise et jetée (`_user`), alors
402    // que `verify_technical_spec_org_access` est déjà appelé par
403    // `sign_technical_spec` pour cette même entité, dans ce même fichier.
404    verify_technical_spec_org_access(
405        &user,
406        id,
407        &state.technical_spec_use_cases,
408        &state.acp_use_cases,
409    )
410    .await?;
411
412    let spec = state.technical_spec_use_cases.get(id).await?;
413    Ok(HttpResponse::Ok().json(TechnicalSpecDto::from(spec)))
414}
415
416// ---------------------------------------------------------------------------
417// GET /technical-specs?acp_id={uuid}
418// ---------------------------------------------------------------------------
419
420#[utoipa::path(
421    get,
422    path = "/technical-specs",
423    tag = "TechnicalSpec",
424    params(
425        ("acp_id" = Uuid, Query, description = "ACP id to list specs for"),
426    ),
427    responses(
428        (status = 200, description = "List of specs", body = Vec<TechnicalSpecDto>),
429    ),
430)]
431#[get("/technical-specs")]
432pub async fn list_technical_specs(
433    state: web::Data<AppState>,
434    user: AuthenticatedUser,
435    query: web::Query<ListTechnicalSpecsQuery>,
436) -> Result<HttpResponse, AppError> {
437    let acp_id = query.into_inner().acp_id;
438
439    // Cloisonnement (#882) : `acp_id` arrivait en paramètre de requête sans
440    // aucune vérification qu'il relève du mandat de l'appelant — même forme
441    // que `list_call_for_funds` sur `building_id` (#864).
442    verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await?;
443
444    let specs = state.technical_spec_use_cases.list_for_acp(acp_id).await?;
445    let dtos: Vec<TechnicalSpecDto> = specs.into_iter().map(TechnicalSpecDto::from).collect();
446    Ok(HttpResponse::Ok().json(dtos))
447}
448
449// Silence unused-import warning when no consumer references the status type
450// directly (utoipa derives keep it in the public surface).
451#[allow(dead_code)]
452fn _status_type_used(_s: TechnicalSpecStatus) {}