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    // Read file content
77    let file_content = match std::fs::read(form.file.file.path()) {
78        Ok(content) => content,
79        Err(e) => {
80            return HttpResponse::InternalServerError().json(format!("Failed to read file: {}", e))
81        }
82    };
83
84    // Upload document
85    match app_state
86        .document_use_cases
87        .upload_document(
88            organization_id,
89            building_id,
90            document_type,
91            form.title.0.clone(),
92            form.description.map(|d| d.0),
93            filename,
94            file_content,
95            mime_type,
96            uploaded_by,
97        )
98        .await
99    {
100        Ok(document) => {
101            // Audit log: successful document upload
102            AuditLogEntry::new(
103                AuditEventType::DocumentUploaded,
104                Some(user.user_id),
105                Some(organization_id),
106            )
107            .with_resource("Document", document.id)
108            .log();
109
110            HttpResponse::Created().json(document)
111        }
112        Err(e) => {
113            // Audit log: failed document upload
114            AuditLogEntry::new(
115                AuditEventType::DocumentUploaded,
116                Some(user.user_id),
117                Some(organization_id),
118            )
119            .with_error(e.clone())
120            .log();
121
122            HttpResponse::InternalServerError().json(e)
123        }
124    }
125}
126
127/// Get document metadata by ID
128#[get("/documents/{id}")]
129pub async fn get_document(app_state: web::Data<AppState>, path: web::Path<Uuid>) -> impl Responder {
130    let id = path.into_inner();
131
132    match app_state.document_use_cases.get_document(id).await {
133        Ok(document) => HttpResponse::Ok().json(document),
134        Err(e) => HttpResponse::NotFound().json(e),
135    }
136}
137
138/// List all documents with pagination
139#[get("/documents")]
140pub async fn list_documents(
141    app_state: web::Data<AppState>,
142    user: AuthenticatedUser,
143    page_request: web::Query<PageRequest>,
144) -> impl Responder {
145    let organization_id = user.organization_id;
146
147    match app_state
148        .document_use_cases
149        .list_documents_paginated(&page_request, organization_id)
150        .await
151    {
152        Ok((documents, total)) => {
153            let response =
154                PageResponse::new(documents, page_request.page, page_request.per_page, total);
155            HttpResponse::Ok().json(response)
156        }
157        Err(e) => HttpResponse::InternalServerError().json(e),
158    }
159}
160
161/// Download document file
162#[get("/documents/{id}/download")]
163pub async fn download_document(
164    app_state: web::Data<AppState>,
165    path: web::Path<Uuid>,
166) -> impl Responder {
167    let id = path.into_inner();
168
169    match app_state.document_use_cases.download_document(id).await {
170        Ok((content, mime_type, filename)) => HttpResponse::Ok()
171            .content_type(mime_type)
172            .insert_header((
173                "Content-Disposition",
174                format!("attachment; filename=\"{}\"", filename),
175            ))
176            .body(content),
177        Err(e) => HttpResponse::NotFound().json(e),
178    }
179}
180
181/// List all documents for a building
182#[get("/buildings/{building_id}/documents")]
183pub async fn list_documents_by_building(
184    app_state: web::Data<AppState>,
185    path: web::Path<Uuid>,
186) -> impl Responder {
187    let building_id = path.into_inner();
188
189    match app_state
190        .document_use_cases
191        .list_documents_by_building(building_id)
192        .await
193    {
194        Ok(documents) => HttpResponse::Ok().json(documents),
195        Err(e) => HttpResponse::InternalServerError().json(e),
196    }
197}
198
199/// List all documents for a meeting
200#[get("/meetings/{meeting_id}/documents")]
201pub async fn list_documents_by_meeting(
202    app_state: web::Data<AppState>,
203    path: web::Path<Uuid>,
204) -> impl Responder {
205    let meeting_id = path.into_inner();
206
207    match app_state
208        .document_use_cases
209        .list_documents_by_meeting(meeting_id)
210        .await
211    {
212        Ok(documents) => HttpResponse::Ok().json(documents),
213        Err(e) => HttpResponse::InternalServerError().json(e),
214    }
215}
216
217/// List all documents for an expense
218#[get("/expenses/{expense_id}/documents")]
219pub async fn list_documents_by_expense(
220    app_state: web::Data<AppState>,
221    path: web::Path<Uuid>,
222) -> impl Responder {
223    let expense_id = path.into_inner();
224
225    match app_state
226        .document_use_cases
227        .list_documents_by_expense(expense_id)
228        .await
229    {
230        Ok(documents) => HttpResponse::Ok().json(documents),
231        Err(e) => HttpResponse::InternalServerError().json(e),
232    }
233}
234
235/// Link document to a meeting
236#[put("/documents/{id}/link-meeting")]
237pub async fn link_document_to_meeting(
238    app_state: web::Data<AppState>,
239    path: web::Path<Uuid>,
240    request: web::Json<LinkDocumentToMeetingRequest>,
241) -> impl Responder {
242    let id = path.into_inner();
243
244    match app_state
245        .document_use_cases
246        .link_to_meeting(id, request.into_inner())
247        .await
248    {
249        Ok(document) => HttpResponse::Ok().json(document),
250        Err(e) => HttpResponse::NotFound().json(e),
251    }
252}
253
254/// Link document to an expense
255#[put("/documents/{id}/link-expense")]
256pub async fn link_document_to_expense(
257    app_state: web::Data<AppState>,
258    path: web::Path<Uuid>,
259    request: web::Json<LinkDocumentToExpenseRequest>,
260) -> impl Responder {
261    let id = path.into_inner();
262
263    match app_state
264        .document_use_cases
265        .link_to_expense(id, request.into_inner())
266        .await
267    {
268        Ok(document) => HttpResponse::Ok().json(document),
269        Err(e) => HttpResponse::NotFound().json(e),
270    }
271}
272
273/// Delete a document
274#[delete("/documents/{id}")]
275pub async fn delete_document(
276    app_state: web::Data<AppState>,
277    user: AuthenticatedUser,
278    path: web::Path<Uuid>,
279) -> impl Responder {
280    let id = path.into_inner();
281
282    match app_state.document_use_cases.delete_document(id).await {
283        Ok(true) => {
284            // Audit log: successful document deletion
285            AuditLogEntry::new(
286                AuditEventType::DocumentDeleted,
287                Some(user.user_id),
288                user.organization_id,
289            )
290            .with_resource("Document", id)
291            .log();
292
293            HttpResponse::NoContent().finish()
294        }
295        Ok(false) => HttpResponse::NotFound().json("Document not found"),
296        Err(e) => {
297            // Audit log: failed document deletion
298            AuditLogEntry::new(
299                AuditEventType::DocumentDeleted,
300                Some(user.user_id),
301                user.organization_id,
302            )
303            .with_resource("Document", id)
304            .with_error(e.clone())
305            .log();
306
307            HttpResponse::InternalServerError().json(e)
308        }
309    }
310}