1use 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#[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 pub version: String,
44 pub deliverables: Vec<String>,
45 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 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 pub role: String,
73 #[serde(default)]
76 pub mandate_id: Option<Uuid>,
77}
78
79#[derive(Debug, Deserialize, utoipa::ToSchema)]
80pub struct ListTechnicalSpecsQuery {
81 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
151fn 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#[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#[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 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#[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 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#[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 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 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#[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 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#[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 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#[allow(dead_code)]
452fn _status_type_used(_s: TechnicalSpecStatus) {}