Skip to main content

koprogo_api/infrastructure/web/handlers/
work_report_handlers.rs

1use crate::application::dto::{
2    AddDocumentDto, AddPhotoDto, CreateWorkReportDto, PageRequest, UpdateWorkReportDto,
3    WorkReportFilters,
4};
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::middleware::scope_guard::verify_acp_org_access;
7use crate::infrastructure::web::{AppState, AuthenticatedUser};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
9use uuid::Uuid;
10
11// ==================== Work Report CRUD Endpoints ====================
12
13/// Create a new work report
14#[post("/work-reports")]
15pub async fn create_work_report(
16    state: web::Data<AppState>,
17    user: AuthenticatedUser,
18    request: web::Json<CreateWorkReportDto>,
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        .work_report_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 rapport de travaux
41        // au nom d'une autre organisation (même famille que l'ADR-0045).
42        .create_work_report({
43            let mut dto = request.into_inner();
44            dto.organization_id = organization_id.to_string();
45            dto
46        })
47        .await
48    {
49        Ok(work_report) => {
50            AuditLogEntry::new(
51                AuditEventType::WorkReportCreated,
52                Some(user.user_id),
53                Some(organization_id),
54            )
55            .with_resource("WorkReport", work_report.id.parse().unwrap())
56            .log();
57
58            HttpResponse::Created().json(work_report)
59        }
60        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
61    }
62}
63
64/// Get work report by ID
65#[get("/work-reports/{id}")]
66pub async fn get_work_report(
67    state: web::Data<AppState>,
68    user: AuthenticatedUser,
69    id: web::Path<Uuid>,
70) -> impl Responder {
71    match state.work_report_use_cases.get_work_report(*id).await {
72        Ok(Some(work_report)) => {
73            // Parse building_id (it's a String in the DTO) and fetch building to get organization_id
74            let building_id = match uuid::Uuid::parse_str(&work_report.building_id) {
75                Ok(bid) => bid,
76                Err(_) => {
77                    return HttpResponse::InternalServerError()
78                        .json(serde_json::json!({"error": "Invalid building_id format"}))
79                }
80            };
81
82            match state.building_use_cases.get_building(building_id).await {
83                Ok(Some(building)) => {
84                    // Hotfix #603 — multi-tenant isolation via ACP→organization resolution.
85                    let acp_id = match Uuid::parse_str(&building.acp_id) {
86                        Ok(id) => id,
87                        Err(_) => {
88                            return HttpResponse::InternalServerError().json(serde_json::json!({
89                                "error": "Invalid building.acp_id format"
90                            }));
91                        }
92                    };
93                    if let Err(err) =
94                        verify_acp_org_access(&user, acp_id, &state.acp_use_cases).await
95                    {
96                        return err.error_response();
97                    }
98                    HttpResponse::Ok().json(work_report)
99                }
100                Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
101                    "error": "Building not found"
102                })),
103                Err(err) => {
104                    HttpResponse::InternalServerError().json(serde_json::json!({"error": err}))
105                }
106            }
107        }
108        Ok(None) => HttpResponse::NotFound().json(serde_json::json!({
109            "error": "Work report not found"
110        })),
111        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
112    }
113}
114
115/// List work reports by building
116#[get("/buildings/{building_id}/work-reports")]
117pub async fn list_building_work_reports(
118    state: web::Data<AppState>,
119    building_id: web::Path<Uuid>,
120    user: AuthenticatedUser,
121) -> impl Responder {
122    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
123    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
124    // un identifiant, sans demander d'identite.
125    if let Err(err) =
126        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
127            &user,
128            *building_id,
129            &state.building_use_cases,
130            &state.acp_use_cases,
131        )
132        .await
133    {
134        return err.error_response();
135    }
136
137    match state
138        .work_report_use_cases
139        .list_work_reports_by_building(*building_id)
140        .await
141    {
142        Ok(work_reports) => HttpResponse::Ok().json(work_reports),
143        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
144    }
145}
146
147/// List work reports by organization
148#[get("/organizations/{organization_id}/work-reports")]
149pub async fn list_organization_work_reports(
150    state: web::Data<AppState>,
151    user: AuthenticatedUser,
152    organization_id: web::Path<Uuid>,
153) -> impl Responder {
154    if let Err(e) = user.verify_org_access(*organization_id) {
155        return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
156    }
157    match state
158        .work_report_use_cases
159        .list_work_reports_by_organization(*organization_id)
160        .await
161    {
162        Ok(work_reports) => HttpResponse::Ok().json(work_reports),
163        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
164    }
165}
166
167/// List work reports with pagination and filters
168#[get("/work-reports")]
169pub async fn list_work_reports_paginated(
170    state: web::Data<AppState>,
171    user: AuthenticatedUser,
172    page_request: web::Query<PageRequest>,
173    filters: web::Query<WorkReportFilters>,
174) -> impl Responder {
175    // Cloisonnement (#882) : même défaut que les contrôles techniques
176    // (`list_technical_inspections_paginated`) — `organization_id` et
177    // `building_id` sont des paramètres CLIENT, et `organization_id` n'était
178    // même pas traduit en clause SQL. Un rapport de travaux porte le
179    // prestataire, le prix et parfois des photos d'un chantier : pas une
180    // donnée à laisser lire hors de son organisation.
181    let mut filters = filters.into_inner();
182
183    match filters.organization_id {
184        Some(org_id) => {
185            if let Err(e) = user.verify_org_access(org_id) {
186                return HttpResponse::Forbidden().json(serde_json::json!({"error": e}));
187            }
188        }
189        None => {
190            filters.organization_id = user.effective_org_filter();
191        }
192    }
193
194    if let Some(building_id) = filters.building_id {
195        if let Err(err) =
196            crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
197                &user,
198                building_id,
199                &state.building_use_cases,
200                &state.acp_use_cases,
201            )
202            .await
203        {
204            return err.error_response();
205        }
206    }
207
208    match state
209        .work_report_use_cases
210        .list_work_reports_paginated(&page_request.into_inner(), &filters)
211        .await
212    {
213        Ok(response) => HttpResponse::Ok().json(response),
214        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
215    }
216}
217
218/// Update work report
219#[put("/work-reports/{id}")]
220pub async fn update_work_report(
221    state: web::Data<AppState>,
222    user: AuthenticatedUser,
223    id: web::Path<Uuid>,
224    request: web::Json<UpdateWorkReportDto>,
225) -> impl Responder {
226    let organization_id = match user.require_organization() {
227        Ok(org_id) => org_id,
228        Err(e) => {
229            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
230        }
231    };
232
233    match state
234        .work_report_use_cases
235        .update_work_report(*id, request.into_inner())
236        .await
237    {
238        Ok(work_report) => {
239            AuditLogEntry::new(
240                AuditEventType::WorkReportUpdated,
241                Some(user.user_id),
242                Some(organization_id),
243            )
244            .with_resource("WorkReport", *id)
245            .log();
246
247            HttpResponse::Ok().json(work_report)
248        }
249        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
250    }
251}
252
253/// Delete work report
254#[delete("/work-reports/{id}")]
255pub async fn delete_work_report(
256    state: web::Data<AppState>,
257    user: AuthenticatedUser,
258    id: web::Path<Uuid>,
259) -> impl Responder {
260    let organization_id = match user.require_organization() {
261        Ok(org_id) => org_id,
262        Err(e) => {
263            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
264        }
265    };
266
267    match state.work_report_use_cases.delete_work_report(*id).await {
268        Ok(deleted) => {
269            if deleted {
270                AuditLogEntry::new(
271                    AuditEventType::WorkReportDeleted,
272                    Some(user.user_id),
273                    Some(organization_id),
274                )
275                .with_resource("WorkReport", *id)
276                .log();
277
278                HttpResponse::NoContent().finish()
279            } else {
280                HttpResponse::NotFound().json(serde_json::json!({
281                    "error": "Work report not found"
282                }))
283            }
284        }
285        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
286    }
287}
288
289// ==================== Warranty Management Endpoints ====================
290
291/// Get active warranties for a building
292#[get("/buildings/{building_id}/work-reports/warranties/active")]
293pub async fn get_active_warranties(
294    state: web::Data<AppState>,
295    building_id: web::Path<Uuid>,
296    user: AuthenticatedUser,
297) -> impl Responder {
298    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
299    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
300    // un identifiant, sans demander d'identite.
301    if let Err(err) =
302        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
303            &user,
304            *building_id,
305            &state.building_use_cases,
306            &state.acp_use_cases,
307        )
308        .await
309    {
310        return err.error_response();
311    }
312
313    match state
314        .work_report_use_cases
315        .get_active_warranties(*building_id)
316        .await
317    {
318        Ok(warranties) => HttpResponse::Ok().json(warranties),
319        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
320    }
321}
322
323/// Get expiring warranties for a building (within X days)
324#[get("/buildings/{building_id}/work-reports/warranties/expiring")]
325pub async fn get_expiring_warranties(
326    state: web::Data<AppState>,
327    user: AuthenticatedUser,
328    path: web::Path<Uuid>,
329    query: web::Query<serde_json::Value>,
330) -> impl Responder {
331    let building_id = path.into_inner();
332    let days = query.get("days").and_then(|v| v.as_i64()).unwrap_or(90) as i32;
333
334    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772) : elle
335    // servait une sous-collection d'un dossier d'ACP a quiconque connaissait
336    // un identifiant, sans demander d'identite.
337    if let Err(err) =
338        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
339            &user,
340            building_id,
341            &state.building_use_cases,
342            &state.acp_use_cases,
343        )
344        .await
345    {
346        return err.error_response();
347    }
348
349    match state
350        .work_report_use_cases
351        .get_expiring_warranties(building_id, days)
352        .await
353    {
354        Ok(warranties) => HttpResponse::Ok().json(warranties),
355        Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": err})),
356    }
357}
358
359// ==================== Photo & Document Management ====================
360
361/// Add photo to work report
362#[post("/work-reports/{id}/photos")]
363pub async fn add_photo(
364    state: web::Data<AppState>,
365    user: AuthenticatedUser,
366    id: web::Path<Uuid>,
367    request: web::Json<AddPhotoDto>,
368) -> impl Responder {
369    let organization_id = match user.require_organization() {
370        Ok(org_id) => org_id,
371        Err(e) => {
372            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
373        }
374    };
375
376    match state
377        .work_report_use_cases
378        .add_photo(*id, request.into_inner())
379        .await
380    {
381        Ok(work_report) => {
382            AuditLogEntry::new(
383                AuditEventType::WorkReportPhotoAdded,
384                Some(user.user_id),
385                Some(organization_id),
386            )
387            .with_resource("WorkReport", *id)
388            .log();
389
390            HttpResponse::Ok().json(work_report)
391        }
392        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
393    }
394}
395
396/// Add document to work report
397#[post("/work-reports/{id}/documents")]
398pub async fn add_document(
399    state: web::Data<AppState>,
400    user: AuthenticatedUser,
401    id: web::Path<Uuid>,
402    request: web::Json<AddDocumentDto>,
403) -> impl Responder {
404    let organization_id = match user.require_organization() {
405        Ok(org_id) => org_id,
406        Err(e) => {
407            return HttpResponse::Unauthorized().json(serde_json::json!({"error": e.to_string()}))
408        }
409    };
410
411    match state
412        .work_report_use_cases
413        .add_document(*id, request.into_inner())
414        .await
415    {
416        Ok(work_report) => {
417            AuditLogEntry::new(
418                AuditEventType::WorkReportDocumentAdded,
419                Some(user.user_id),
420                Some(organization_id),
421            )
422            .with_resource("WorkReport", *id)
423            .log();
424
425            HttpResponse::Ok().json(work_report)
426        }
427        Err(err) => HttpResponse::BadRequest().json(serde_json::json!({"error": err})),
428    }
429}