Skip to main content

koprogo_api/infrastructure/web/handlers/
technical_inspection_handlers.rs

1use crate::application::dto::{
2    AddCertificateDto, AddInspectionPhotoDto, AddReportDto, CreateTechnicalInspectionDto,
3    PageRequest, TechnicalInspectionFilters, UpdateTechnicalInspectionDto,
4};
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::middleware::scope_guard::verify_building_org_access;
7use crate::infrastructure::web::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
9use uuid::Uuid;
10
11// ==================== Technical Inspection CRUD Endpoints ====================
12
13/// Create a new technical inspection
14#[post("/technical-inspections")]
15pub async fn create_technical_inspection(
16    state: web::Data<AppState>,
17    user: AuthenticatedUser,
18    request: web::Json<CreateTechnicalInspectionDto>,
19) -> impl Responder {
20    let organization_id = match user.require_organization() {
21        Ok(org_id) => org_id,
22        Err(e) => {
23            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
24        }
25    };
26
27    match state
28        .technical_inspection_use_cases
29        // L'organisation vient du JETON, jamais du corps de la requête.
30        //
31        // Elle y était pourtant attendue, et le client ne l'avait pas toujours :
32        // sur `/building-detail`, `organizationId` est résolu via
33        // `getAcp(building.acp_id)`, qui **dégrade silencieusement en 403** pour
34        // un syndic ou un copropriétaire. Le formulaire postait alors une chaîne
35        // vide, `Uuid::parse_str("")` échouait, et le serveur répondait 400 sans
36        // que rien n'indique quel champ posait problème (#552).
37        //
38        // Le handler calculait déjà cette organisation — `require_organization()`
39        // ci-dessus — puis la jetait. La lire du jeton corrige le 400 **et**
40        // ferme une porte : un client ne peut plus estampiller un inspection technique
41        // au nom d'une autre organisation (même famille que l'ADR-0045).
42        .create_technical_inspection({
43            let mut dto = request.into_inner();
44            dto.organization_id = organization_id.to_string();
45            dto
46        })
47        .await
48    {
49        Ok(inspection) => {
50            AuditLogEntry::new(
51                AuditEventType::TechnicalInspectionCreated,
52                Some(user.user_id),
53                Some(organization_id),
54            )
55            .with_resource("TechnicalInspection", inspection.id.parse().unwrap())
56            .log();
57
58            HttpResponse::Created().json(inspection)
59        }
60        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
61    }
62}
63
64/// Get technical inspection by ID
65#[get("/technical-inspections/{id}")]
66pub async fn get_technical_inspection(
67    state: web::Data<AppState>,
68    user: AuthenticatedUser,
69    id: web::Path<Uuid>,
70) -> impl Responder {
71    // Cette route ne prenait AUCUNE identité : ni `AuthenticatedUser`, ni
72    // jeton lu à la main. Le cliquet de #772 ne la voyait pas — il ne
73    // compte que les routes PRENANT une identité sans s'en servir.
74    // Cf. #845.
75
76    match state
77        .technical_inspection_use_cases
78        .get_technical_inspection(*id)
79        .await
80    {
81        Ok(Some(inspection)) => {
82            // Un contrôle technique porte l'organisation de son immeuble : on
83            // refuse celui d'une autre copropriété plutôt que de le servir.
84            match Uuid::parse_str(&inspection.organization_id) {
85                Ok(org) => match user.verify_org_access(org) {
86                    Ok(()) => HttpResponse::Ok().json(inspection),
87                    Err(e) => HttpResponse::Forbidden().json(serde_json::json!({ "error": e })),
88                },
89                Err(_) => HttpResponse::InternalServerError()
90                    .json(serde_json::json!({"error": "Invalid organization_id"})),
91            }
92        }
93        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
94            "error": "Technical inspection not found"
95        })),
96        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
97    }
98}
99
100/// List technical inspections by building
101#[get("/buildings/{building_id}/technical-inspections")]
102pub async fn list_building_technical_inspections(
103    state: web::Data<AppState>,
104    building_id: web::Path<Uuid>,
105    user: AuthenticatedUser,
106) -> impl Responder {
107    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
108    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
109    // un identifiant, sans demander d'identite.
110    if let Err(err) =
111        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
112            &user,
113            *building_id,
114            &state.building_use_cases,
115            &state.acp_use_cases,
116        )
117        .await
118    {
119        return err.error_response();
120    }
121
122    match state
123        .technical_inspection_use_cases
124        .list_technical_inspections_by_building(*building_id)
125        .await
126    {
127        Ok(inspections) => HttpResponse::Ok().json(inspections),
128        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
129    }
130}
131
132/// List technical inspections by organization
133#[get("/organizations/{organization_id}/technical-inspections")]
134pub async fn list_organization_technical_inspections(
135    state: web::Data<AppState>,
136    user: AuthenticatedUser,
137    organization_id: web::Path<Uuid>,
138) -> impl Responder {
139    if let Err(e) = user.verify_org_access(*organization_id) {
140        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
141    }
142    match state
143        .technical_inspection_use_cases
144        .list_technical_inspections_by_organization(*organization_id)
145        .await
146    {
147        Ok(inspections) => HttpResponse::Ok().json(inspections),
148        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
149    }
150}
151
152/// List technical inspections with pagination and filters
153#[get("/technical-inspections")]
154pub async fn list_technical_inspections_paginated(
155    state: web::Data<AppState>,
156    user: AuthenticatedUser,
157    page_request: web::Query<PageRequest>,
158    filters: web::Query<TechnicalInspectionFilters>,
159) -> impl Responder {
160    // Cloisonnement (#882) : `organization_id` ET `building_id` sont des
161    // paramètres de requête CLIENT, transmis tels quels au filtre — et le
162    // filtre lui-même ne traduisait pas `organization_id` en clause SQL.
163    // Un `organization_id` arbitraire suffisait donc à lire les contrôles
164    // techniques d'une autre organisation, sans même que le filtrage
165    // n'échoue silencieusement.
166    let mut filters = filters.into_inner();
167
168    match filters.organization_id {
169        Some(org_id) => {
170            if let Err(e) = user.verify_org_access(org_id) {
171                return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
172            }
173        }
174        None => {
175            // Non fourni : borné à l'organisation de l'appelant. `None` pour
176            // un superadministrateur, qui voit alors toute l'instance —
177            // c'est le rôle qui l'y autorise, pas l'absence de filtre.
178            filters.organization_id = user.effective_org_filter();
179        }
180    }
181
182    if let Some(building_id) = filters.building_id {
183        if let Err(err) = verify_building_org_access(
184            &user,
185            building_id,
186            &state.building_use_cases,
187            &state.acp_use_cases,
188        )
189        .await
190        {
191            return err.error_response();
192        }
193    }
194
195    match state
196        .technical_inspection_use_cases
197        .list_technical_inspections_paginated(&page_request.into_inner(), &filters)
198        .await
199    {
200        Ok(response) => HttpResponse::Ok().json(response),
201        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
202    }
203}
204
205/// Update technical inspection
206#[put("/technical-inspections/{id}")]
207pub async fn update_technical_inspection(
208    state: web::Data<AppState>,
209    user: AuthenticatedUser,
210    id: web::Path<Uuid>,
211    request: web::Json<UpdateTechnicalInspectionDto>,
212) -> impl Responder {
213    let organization_id = match user.require_organization() {
214        Ok(org_id) => org_id,
215        Err(e) => {
216            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
217        }
218    };
219
220    match state
221        .technical_inspection_use_cases
222        .update_technical_inspection(*id, request.into_inner())
223        .await
224    {
225        Ok(inspection) => {
226            AuditLogEntry::new(
227                AuditEventType::TechnicalInspectionUpdated,
228                Some(user.user_id),
229                Some(organization_id),
230            )
231            .with_resource("TechnicalInspection", *id)
232            .log();
233
234            HttpResponse::Ok().json(inspection)
235        }
236        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
237    }
238}
239
240/// Delete technical inspection
241#[delete("/technical-inspections/{id}")]
242pub async fn delete_technical_inspection(
243    state: web::Data<AppState>,
244    user: AuthenticatedUser,
245    id: web::Path<Uuid>,
246) -> impl Responder {
247    let organization_id = match user.require_organization() {
248        Ok(org_id) => org_id,
249        Err(e) => {
250            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
251        }
252    };
253
254    match state
255        .technical_inspection_use_cases
256        .delete_technical_inspection(*id)
257        .await
258    {
259        Ok(deleted) => {
260            if deleted {
261                AuditLogEntry::new(
262                    AuditEventType::TechnicalInspectionDeleted,
263                    Some(user.user_id),
264                    Some(organization_id),
265                )
266                .with_resource("TechnicalInspection", *id)
267                .log();
268
269                HttpResponse::NoContent().finish()
270            } else {
271                HttpResponse::NotFound().json(serde_json::json!({
272                    "error": "Technical inspection not found"
273                }))
274            }
275        }
276        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
277    }
278}
279
280// ==================== Inspection Tracking Endpoints ====================
281
282/// Get overdue inspections for a building
283#[get("/buildings/{building_id}/technical-inspections/overdue")]
284pub async fn get_overdue_inspections(
285    state: web::Data<AppState>,
286    building_id: web::Path<Uuid>,
287    user: AuthenticatedUser,
288) -> impl Responder {
289    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
290    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
291    // un identifiant, sans demander d'identite.
292    if let Err(err) =
293        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
294            &user,
295            *building_id,
296            &state.building_use_cases,
297            &state.acp_use_cases,
298        )
299        .await
300    {
301        return err.error_response();
302    }
303
304    match state
305        .technical_inspection_use_cases
306        .get_overdue_inspections(*building_id)
307        .await
308    {
309        Ok(inspections) => HttpResponse::Ok().json(inspections),
310        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
311    }
312}
313
314/// Get upcoming inspections for a building (within X days)
315#[get("/buildings/{building_id}/technical-inspections/upcoming")]
316pub async fn get_upcoming_inspections(
317    state: web::Data<AppState>,
318    path: web::Path<Uuid>,
319    query: web::Query<serde_json::Value>,
320    user: AuthenticatedUser,
321) -> impl Responder {
322    let building_id = path.into_inner();
323    let days = query.get("days").and_then(|v| v.as_i64()).unwrap_or(90) as i32;
324
325    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772).
326    //
327    // Elle avait recu le parametre `user` sans la garde qui l'emploie : le
328    // cliquet, qui comptait la presence d'`AuthenticatedUser`, l'aurait donc
329    // declaree protegee alors qu'elle ne l'etait pas. Seul l'avertissement
330    // `unused variable` du compilateur l'a signalee. D'ou le second cliquet.
331    if let Err(err) =
332        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
333            &user,
334            building_id,
335            &state.building_use_cases,
336            &state.acp_use_cases,
337        )
338        .await
339    {
340        return err.error_response();
341    }
342
343    match state
344        .technical_inspection_use_cases
345        .get_upcoming_inspections(building_id, days)
346        .await
347    {
348        Ok(inspections) => HttpResponse::Ok().json(inspections),
349        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
350    }
351}
352
353/// Get inspections by type for a building
354#[get("/buildings/{building_id}/technical-inspections/type/{inspection_type}")]
355pub async fn get_inspections_by_type(
356    state: web::Data<AppState>,
357    path: web::Path<(Uuid, String)>,
358    user: AuthenticatedUser,
359) -> impl Responder {
360    let (building_id, inspection_type) = path.into_inner();
361    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
362    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
363    // un identifiant, sans demander d'identite.
364    if let Err(err) =
365        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
366            &user,
367            building_id,
368            &state.building_use_cases,
369            &state.acp_use_cases,
370        )
371        .await
372    {
373        return err.error_response();
374    }
375
376    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
377    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
378    // un identifiant, sans demander d'identite.
379    if let Err(err) =
380        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
381            &user,
382            building_id,
383            &state.building_use_cases,
384            &state.acp_use_cases,
385        )
386        .await
387    {
388        return err.error_response();
389    }
390
391    match state
392        .technical_inspection_use_cases
393        .get_inspections_by_type(building_id, &inspection_type)
394        .await
395    {
396        Ok(inspections) => HttpResponse::Ok().json(inspections),
397        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
398    }
399}
400
401// ==================== Document Management ====================
402
403/// Add report to technical inspection
404#[post("/technical-inspections/{id}/reports")]
405pub async fn add_report(
406    state: web::Data<AppState>,
407    user: AuthenticatedUser,
408    id: web::Path<Uuid>,
409    request: web::Json<AddReportDto>,
410) -> impl Responder {
411    let organization_id = match user.require_organization() {
412        Ok(org_id) => org_id,
413        Err(e) => {
414            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
415        }
416    };
417
418    match state
419        .technical_inspection_use_cases
420        .add_report(*id, request.into_inner())
421        .await
422    {
423        Ok(inspection) => {
424            AuditLogEntry::new(
425                AuditEventType::TechnicalInspectionReportAdded,
426                Some(user.user_id),
427                Some(organization_id),
428            )
429            .with_resource("TechnicalInspection", *id)
430            .log();
431
432            HttpResponse::Ok().json(inspection)
433        }
434        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
435    }
436}
437
438/// Add photo to technical inspection
439#[post("/technical-inspections/{id}/photos")]
440pub async fn add_inspection_photo(
441    state: web::Data<AppState>,
442    user: AuthenticatedUser,
443    id: web::Path<Uuid>,
444    request: web::Json<AddInspectionPhotoDto>,
445) -> impl Responder {
446    let organization_id = match user.require_organization() {
447        Ok(org_id) => org_id,
448        Err(e) => {
449            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
450        }
451    };
452
453    match state
454        .technical_inspection_use_cases
455        .add_photo(*id, request.into_inner())
456        .await
457    {
458        Ok(inspection) => {
459            AuditLogEntry::new(
460                AuditEventType::TechnicalInspectionPhotoAdded,
461                Some(user.user_id),
462                Some(organization_id),
463            )
464            .with_resource("TechnicalInspection", *id)
465            .log();
466
467            HttpResponse::Ok().json(inspection)
468        }
469        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
470    }
471}
472
473/// Add certificate to technical inspection
474#[post("/technical-inspections/{id}/certificates")]
475pub async fn add_certificate(
476    state: web::Data<AppState>,
477    user: AuthenticatedUser,
478    id: web::Path<Uuid>,
479    request: web::Json<AddCertificateDto>,
480) -> impl Responder {
481    let organization_id = match user.require_organization() {
482        Ok(org_id) => org_id,
483        Err(e) => {
484            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
485        }
486    };
487
488    match state
489        .technical_inspection_use_cases
490        .add_certificate(*id, request.into_inner())
491        .await
492    {
493        Ok(inspection) => {
494            AuditLogEntry::new(
495                AuditEventType::TechnicalInspectionCertificateAdded,
496                Some(user.user_id),
497                Some(organization_id),
498            )
499            .with_resource("TechnicalInspection", *id)
500            .log();
501
502            HttpResponse::Ok().json(inspection)
503        }
504        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
505    }
506}