Skip to main content

koprogo_api/infrastructure/web/handlers/
document_handlers.rs

1use crate::application::dto::{
2    LinkDocumentToExpenseRequest, LinkDocumentToMeetingRequest, PageRequest, PageResponse,
3};
4use crate::domain::entities::DocumentType;
5use crate::infrastructure::audit::{AuditEventType, AuditLogEntry};
6use crate::infrastructure::web::{app_state::AppState, AuthenticatedUser};
7use actix_multipart::form::{tempfile::TempFile, text::Text, MultipartForm};
8use actix_web::{delete, get, post, put, web, HttpResponse, Responder, ResponseError};
9use uuid::Uuid;
10
11#[derive(Debug, MultipartForm)]
12pub struct UploadForm {
13    #[multipart(limit = "50MB")]
14    file: TempFile,
15    building_id: Text<String>,
16    document_type: Text<String>,
17    title: Text<String>,
18    description: Option<Text<String>>,
19    uploaded_by: Text<String>,
20}
21
22/// Upload a document with multipart/form-data
23#[post("/documents")]
24pub async fn upload_document(
25    app_state: web::Data<AppState>,
26    user: AuthenticatedUser, // JWT-extracted user info (SECURE!)
27    MultipartForm(form): MultipartForm<UploadForm>,
28) -> impl Responder {
29    // Use organization_id from JWT token (SECURE - cannot be forged!)
30    let organization_id = match user.require_organization() {
31        Ok(org_id) => org_id,
32        Err(e) => {
33            return HttpResponse::Unauthorized().json(serde_json::json!({
34                "error": e.to_string()
35            }))
36        }
37    };
38
39    // Parse building_id
40    let building_id = match Uuid::parse_str(&form.building_id.0) {
41        Ok(id) => id,
42        Err(_) => return HttpResponse::BadRequest().json("Invalid building_id"),
43    };
44
45    // Parse document_type
46    let document_type = match form.document_type.0.as_str() {
47        "meeting_minutes" | "MeetingMinutes" => DocumentType::MeetingMinutes,
48        "financial_statement" | "FinancialStatement" => DocumentType::FinancialStatement,
49        "invoice" | "Invoice" => DocumentType::Invoice,
50        "contract" | "Contract" => DocumentType::Contract,
51        "regulation" | "Regulation" => DocumentType::Regulation,
52        "works_quote" | "WorksQuote" => DocumentType::WorksQuote,
53        "other" | "Other" => DocumentType::Other,
54        _ => return HttpResponse::BadRequest().json("Invalid document_type"),
55    };
56
57    // Parse uploaded_by
58    let uploaded_by = match Uuid::parse_str(&form.uploaded_by.0) {
59        Ok(id) => id,
60        Err(_) => return HttpResponse::BadRequest().json("Invalid uploaded_by"),
61    };
62
63    // Get file metadata
64    let filename = form
65        .file
66        .file_name
67        .clone()
68        .unwrap_or_else(|| "unnamed".to_string());
69    let mime_type = form
70        .file
71        .content_type
72        .as_ref()
73        .map(|ct| ct.to_string())
74        .unwrap_or_else(|| "application/octet-stream".to_string());
75
76    // Enforce file size limit before reading into memory (prevent uncontrolled allocation)
77    const MAX_FILE_SIZE: usize = 50 * 1024 * 1024; // 50MB
78    if form.file.size > MAX_FILE_SIZE {
79        return HttpResponse::PayloadTooLarge().json(serde_json::json!({
80            "error": "File too large. Maximum allowed size is 50MB."
81        }));
82    }
83
84    // Read file content
85    let file_content = match std::fs::read(form.file.file.path()) {
86        Ok(content) => content,
87        Err(e) => {
88            return HttpResponse::InternalServerError().json(format!("Failed to read file: {}", e))
89        }
90    };
91
92    // Upload document
93    match app_state
94        .document_use_cases
95        .upload_document(
96            organization_id,
97            building_id,
98            document_type,
99            form.title.0.clone(),
100            form.description.map(|d| d.0),
101            filename,
102            file_content,
103            mime_type,
104            uploaded_by,
105        )
106        .await
107    {
108        Ok(document) => {
109            // Audit log: successful document upload
110            AuditLogEntry::new(
111                AuditEventType::DocumentUploaded,
112                Some(user.user_id),
113                Some(organization_id),
114            )
115            .with_resource("Document", document.id)
116            .log();
117
118            HttpResponse::Created().json(document)
119        }
120        Err(e) => {
121            // Audit log: failed document upload
122            AuditLogEntry::new(
123                AuditEventType::DocumentUploaded,
124                Some(user.user_id),
125                Some(organization_id),
126            )
127            .with_error(e.clone())
128            .log();
129
130            HttpResponse::InternalServerError().json(e)
131        }
132    }
133}
134
135/// Get document metadata by ID
136#[get("/documents/{id}")]
137pub async fn get_document(
138    app_state: web::Data<AppState>,
139    user: AuthenticatedUser,
140    path: web::Path<Uuid>,
141) -> impl Responder {
142    let id = path.into_inner();
143
144    // Aucune identité n'était exigée ici : ni `AuthenticatedUser`, ni jeton
145    // lu à la main. N'importe qui pouvait lire n'importe quel document de
146    // n'importe quelle copropriété, sur simple connaissance de son identifiant.
147    //
148    // Le cliquet d'identité de #772 ne pouvait pas le voir : il compte les
149    // routes qui PRENNENT `AuthenticatedUser` sans s'en servir. Une route qui
150    // ne le prend pas du tout lui échappait entièrement. Cf. #845.
151    if let Err(err) =
152        crate::infrastructure::web::middleware::scope_guard::verify_document_org_access(
153            &user,
154            id,
155            &app_state.document_use_cases,
156            &app_state.building_use_cases,
157            &app_state.acp_use_cases,
158        )
159        .await
160    {
161        return err.error_response();
162    }
163
164    match app_state.document_use_cases.get_document(id).await {
165        Ok(document) => HttpResponse::Ok().json(document),
166        Err(e) => HttpResponse::NotFound().json(e),
167    }
168}
169
170/// List all documents with pagination
171#[get("/documents")]
172pub async fn list_documents(
173    app_state: web::Data<AppState>,
174    user: AuthenticatedUser,
175    page_request: web::Query<PageRequest>,
176) -> impl Responder {
177    let organization_id = user.organization_id;
178
179    match app_state
180        .document_use_cases
181        .list_documents_paginated(&page_request, organization_id)
182        .await
183    {
184        Ok((documents, total)) => {
185            let response =
186                PageResponse::new(documents, page_request.page, page_request.per_page, total);
187            HttpResponse::Ok().json(response)
188        }
189        Err(e) => HttpResponse::InternalServerError().json(e),
190    }
191}
192
193/// Download document file
194#[get("/documents/{id}/download")]
195pub async fn download_document(
196    app_state: web::Data<AppState>,
197    path: web::Path<Uuid>,
198    user: AuthenticatedUser,
199) -> impl Responder {
200    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772).
201    if let Err(err) =
202        crate::infrastructure::web::middleware::scope_guard::verify_document_org_access(
203            &user,
204            *path,
205            &app_state.document_use_cases,
206            &app_state.building_use_cases,
207            &app_state.acp_use_cases,
208        )
209        .await
210    {
211        return err.error_response();
212    }
213
214    let id = path.into_inner();
215
216    match app_state.document_use_cases.download_document(id).await {
217        Ok((content, mime_type, filename)) => HttpResponse::Ok()
218            .content_type(mime_type)
219            .insert_header((
220                "Content-Disposition",
221                format!("attachment; filename=\"{}\"", filename),
222            ))
223            .body(content),
224        Err(e) => HttpResponse::NotFound().json(e),
225    }
226}
227
228/// List all documents for a building
229#[get("/buildings/{building_id}/documents")]
230pub async fn list_documents_by_building(
231    app_state: web::Data<AppState>,
232    user: AuthenticatedUser,
233    path: web::Path<Uuid>,
234) -> impl Responder {
235    let building_id = path.into_inner();
236
237    // Les documents d'un immeuble portent l'acte de base, les procès-verbaux
238    // et des factures nominatives. Cette route les servait à quiconque
239    // connaissait un identifiant d'immeuble, sans demander d'identité — l'une
240    // des 73 routes imbriquées non gardées de l'issue #772.
241    if let Err(err) =
242        crate::infrastructure::web::middleware::scope_guard::verify_building_org_access(
243            &user,
244            building_id,
245            &app_state.building_use_cases,
246            &app_state.acp_use_cases,
247        )
248        .await
249    {
250        return err.error_response();
251    }
252
253    match app_state
254        .document_use_cases
255        .list_documents_by_building(building_id)
256        .await
257    {
258        Ok(documents) => HttpResponse::Ok().json(documents),
259        Err(e) => HttpResponse::InternalServerError().json(e),
260    }
261}
262
263/// List all documents for a meeting
264#[get("/meetings/{meeting_id}/documents")]
265pub async fn list_documents_by_meeting(
266    app_state: web::Data<AppState>,
267    user: AuthenticatedUser,
268    path: web::Path<Uuid>,
269) -> impl Responder {
270    let meeting_id = path.into_inner();
271
272    // Convocations, procès-verbaux, pièces annexées : le dossier d'une
273    // assemblée d'une autre copropriété n'a pas à être lisible. Issue #772.
274    if let Err(err) =
275        crate::infrastructure::web::middleware::scope_guard::verify_meeting_org_access(
276            &user,
277            meeting_id,
278            &app_state.meeting_use_cases,
279            &app_state.building_use_cases,
280            &app_state.acp_use_cases,
281        )
282        .await
283    {
284        return err.error_response();
285    }
286
287    match app_state
288        .document_use_cases
289        .list_documents_by_meeting(meeting_id)
290        .await
291    {
292        Ok(documents) => HttpResponse::Ok().json(documents),
293        Err(e) => HttpResponse::InternalServerError().json(e),
294    }
295}
296
297/// List all documents for an expense
298#[get("/expenses/{expense_id}/documents")]
299pub async fn list_documents_by_expense(
300    app_state: web::Data<AppState>,
301    user: AuthenticatedUser,
302    path: web::Path<Uuid>,
303) -> impl Responder {
304    let expense_id = path.into_inner();
305
306    // Une dépense porte ses factures et ses devis, avec des noms de
307    // fournisseurs et des montants. Issue #772.
308    if let Err(err) =
309        crate::infrastructure::web::middleware::scope_guard::verify_expense_org_access(
310            &user,
311            expense_id,
312            &app_state.expense_use_cases,
313            &app_state.building_use_cases,
314            &app_state.acp_use_cases,
315        )
316        .await
317    {
318        return err.error_response();
319    }
320
321    match app_state
322        .document_use_cases
323        .list_documents_by_expense(expense_id)
324        .await
325    {
326        Ok(documents) => HttpResponse::Ok().json(documents),
327        Err(e) => HttpResponse::InternalServerError().json(e),
328    }
329}
330
331/// Link document to a meeting
332#[put("/documents/{id}/link-meeting")]
333pub async fn link_document_to_meeting(
334    app_state: web::Data<AppState>,
335    path: web::Path<Uuid>,
336    request: web::Json<LinkDocumentToMeetingRequest>,
337    user: AuthenticatedUser,
338) -> impl Responder {
339    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772).
340    if let Err(err) =
341        crate::infrastructure::web::middleware::scope_guard::verify_document_org_access(
342            &user,
343            *path,
344            &app_state.document_use_cases,
345            &app_state.building_use_cases,
346            &app_state.acp_use_cases,
347        )
348        .await
349    {
350        return err.error_response();
351    }
352
353    let id = path.into_inner();
354
355    match app_state
356        .document_use_cases
357        .link_to_meeting(id, request.into_inner())
358        .await
359    {
360        Ok(document) => HttpResponse::Ok().json(document),
361        Err(e) => HttpResponse::NotFound().json(e),
362    }
363}
364
365/// Link document to an expense
366#[put("/documents/{id}/link-expense")]
367pub async fn link_document_to_expense(
368    app_state: web::Data<AppState>,
369    path: web::Path<Uuid>,
370    request: web::Json<LinkDocumentToExpenseRequest>,
371    user: AuthenticatedUser,
372) -> impl Responder {
373    // Route imbriquee non gardee au releve du 2026-09-06 (issue #772).
374    if let Err(err) =
375        crate::infrastructure::web::middleware::scope_guard::verify_document_org_access(
376            &user,
377            *path,
378            &app_state.document_use_cases,
379            &app_state.building_use_cases,
380            &app_state.acp_use_cases,
381        )
382        .await
383    {
384        return err.error_response();
385    }
386
387    let id = path.into_inner();
388
389    match app_state
390        .document_use_cases
391        .link_to_expense(id, request.into_inner())
392        .await
393    {
394        Ok(document) => HttpResponse::Ok().json(document),
395        Err(e) => HttpResponse::NotFound().json(e),
396    }
397}
398
399/// Delete a document
400#[delete("/documents/{id}")]
401pub async fn delete_document(
402    app_state: web::Data<AppState>,
403    user: AuthenticatedUser,
404    path: web::Path<Uuid>,
405) -> impl Responder {
406    let id = path.into_inner();
407
408    // Cloisonnement AVANT la suppression (#864).
409    //
410    // `verify_document_org_access` existe et `get_document` l'appelle trois
411    // cents lignes plus haut. La LECTURE était donc cloisonnée, et la
412    // SUPPRESSION ne l'était pas : `AuthenticatedUser` n'y servait qu'à
413    // journaliser le geste après coup.
414    if let Err(err) =
415        crate::infrastructure::web::middleware::scope_guard::verify_document_org_access(
416            &user,
417            id,
418            &app_state.document_use_cases,
419            &app_state.building_use_cases,
420            &app_state.acp_use_cases,
421        )
422        .await
423    {
424        return err.error_response();
425    }
426
427    match app_state.document_use_cases.delete_document(id).await {
428        Ok(true) => {
429            // Audit log: successful document deletion
430            AuditLogEntry::new(
431                AuditEventType::DocumentDeleted,
432                Some(user.user_id),
433                user.organization_id,
434            )
435            .with_resource("Document", id)
436            .log();
437
438            HttpResponse::NoContent().finish()
439        }
440        Ok(false) => HttpResponse::NotFound().json("Document not found"),
441        Err(e) => {
442            // Audit log: failed document deletion
443            AuditLogEntry::new(
444                AuditEventType::DocumentDeleted,
445                Some(user.user_id),
446                user.organization_id,
447            )
448            .with_resource("Document", id)
449            .with_error(e.clone())
450            .log();
451
452            HttpResponse::InternalServerError().json(e)
453        }
454    }
455}