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};
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(app_state: web::Data<AppState>, path: web::Path<Uuid>) -> impl Responder {
138    let id = path.into_inner();
139
140    match app_state.document_use_cases.get_document(id).await {
141        Ok(document) => HttpResponse::Ok().json(document),
142        Err(e) => HttpResponse::NotFound().json(e),
143    }
144}
145
146/// List all documents with pagination
147#[get("/documents")]
148pub async fn list_documents(
149    app_state: web::Data<AppState>,
150    user: AuthenticatedUser,
151    page_request: web::Query<PageRequest>,
152) -> impl Responder {
153    let organization_id = user.organization_id;
154
155    match app_state
156        .document_use_cases
157        .list_documents_paginated(&page_request, organization_id)
158        .await
159    {
160        Ok((documents, total)) => {
161            let response =
162                PageResponse::new(documents, page_request.page, page_request.per_page, total);
163            HttpResponse::Ok().json(response)
164        }
165        Err(e) => HttpResponse::InternalServerError().json(e),
166    }
167}
168
169/// Download document file
170#[get("/documents/{id}/download")]
171pub async fn download_document(
172    app_state: web::Data<AppState>,
173    path: web::Path<Uuid>,
174) -> impl Responder {
175    let id = path.into_inner();
176
177    match app_state.document_use_cases.download_document(id).await {
178        Ok((content, mime_type, filename)) => HttpResponse::Ok()
179            .content_type(mime_type)
180            .insert_header((
181                "Content-Disposition",
182                format!("attachment; filename=\"{}\"", filename),
183            ))
184            .body(content),
185        Err(e) => HttpResponse::NotFound().json(e),
186    }
187}
188
189/// List all documents for a building
190#[get("/buildings/{building_id}/documents")]
191pub async fn list_documents_by_building(
192    app_state: web::Data<AppState>,
193    path: web::Path<Uuid>,
194) -> impl Responder {
195    let building_id = path.into_inner();
196
197    match app_state
198        .document_use_cases
199        .list_documents_by_building(building_id)
200        .await
201    {
202        Ok(documents) => HttpResponse::Ok().json(documents),
203        Err(e) => HttpResponse::InternalServerError().json(e),
204    }
205}
206
207/// List all documents for a meeting
208#[get("/meetings/{meeting_id}/documents")]
209pub async fn list_documents_by_meeting(
210    app_state: web::Data<AppState>,
211    path: web::Path<Uuid>,
212) -> impl Responder {
213    let meeting_id = path.into_inner();
214
215    match app_state
216        .document_use_cases
217        .list_documents_by_meeting(meeting_id)
218        .await
219    {
220        Ok(documents) => HttpResponse::Ok().json(documents),
221        Err(e) => HttpResponse::InternalServerError().json(e),
222    }
223}
224
225/// List all documents for an expense
226#[get("/expenses/{expense_id}/documents")]
227pub async fn list_documents_by_expense(
228    app_state: web::Data<AppState>,
229    path: web::Path<Uuid>,
230) -> impl Responder {
231    let expense_id = path.into_inner();
232
233    match app_state
234        .document_use_cases
235        .list_documents_by_expense(expense_id)
236        .await
237    {
238        Ok(documents) => HttpResponse::Ok().json(documents),
239        Err(e) => HttpResponse::InternalServerError().json(e),
240    }
241}
242
243/// Link document to a meeting
244#[put("/documents/{id}/link-meeting")]
245pub async fn link_document_to_meeting(
246    app_state: web::Data<AppState>,
247    path: web::Path<Uuid>,
248    request: web::Json<LinkDocumentToMeetingRequest>,
249) -> impl Responder {
250    let id = path.into_inner();
251
252    match app_state
253        .document_use_cases
254        .link_to_meeting(id, request.into_inner())
255        .await
256    {
257        Ok(document) => HttpResponse::Ok().json(document),
258        Err(e) => HttpResponse::NotFound().json(e),
259    }
260}
261
262/// Link document to an expense
263#[put("/documents/{id}/link-expense")]
264pub async fn link_document_to_expense(
265    app_state: web::Data<AppState>,
266    path: web::Path<Uuid>,
267    request: web::Json<LinkDocumentToExpenseRequest>,
268) -> impl Responder {
269    let id = path.into_inner();
270
271    match app_state
272        .document_use_cases
273        .link_to_expense(id, request.into_inner())
274        .await
275    {
276        Ok(document) => HttpResponse::Ok().json(document),
277        Err(e) => HttpResponse::NotFound().json(e),
278    }
279}
280
281/// Delete a document
282#[delete("/documents/{id}")]
283pub async fn delete_document(
284    app_state: web::Data<AppState>,
285    user: AuthenticatedUser,
286    path: web::Path<Uuid>,
287) -> impl Responder {
288    let id = path.into_inner();
289
290    match app_state.document_use_cases.delete_document(id).await {
291        Ok(true) => {
292            // Audit log: successful document deletion
293            AuditLogEntry::new(
294                AuditEventType::DocumentDeleted,
295                Some(user.user_id),
296                user.organization_id,
297            )
298            .with_resource("Document", id)
299            .log();
300
301            HttpResponse::NoContent().finish()
302        }
303        Ok(false) => HttpResponse::NotFound().json("Document not found"),
304        Err(e) => {
305            // Audit log: failed document deletion
306            AuditLogEntry::new(
307                AuditEventType::DocumentDeleted,
308                Some(user.user_id),
309                user.organization_id,
310            )
311            .with_resource("Document", id)
312            .with_error(e.clone())
313            .log();
314
315            HttpResponse::InternalServerError().json(e)
316        }
317    }
318}