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/// Link document to a meeting
218#[put("/documents/{id}/link-meeting")]
219pub async fn link_document_to_meeting(
220    app_state: web::Data<AppState>,
221    path: web::Path<Uuid>,
222    request: web::Json<LinkDocumentToMeetingRequest>,
223) -> impl Responder {
224    let id = path.into_inner();
225
226    match app_state
227        .document_use_cases
228        .link_to_meeting(id, request.into_inner())
229        .await
230    {
231        Ok(document) => HttpResponse::Ok().json(document),
232        Err(e) => HttpResponse::NotFound().json(e),
233    }
234}
235
236/// Link document to an expense
237#[put("/documents/{id}/link-expense")]
238pub async fn link_document_to_expense(
239    app_state: web::Data<AppState>,
240    path: web::Path<Uuid>,
241    request: web::Json<LinkDocumentToExpenseRequest>,
242) -> impl Responder {
243    let id = path.into_inner();
244
245    match app_state
246        .document_use_cases
247        .link_to_expense(id, request.into_inner())
248        .await
249    {
250        Ok(document) => HttpResponse::Ok().json(document),
251        Err(e) => HttpResponse::NotFound().json(e),
252    }
253}
254
255/// Delete a document
256#[delete("/documents/{id}")]
257pub async fn delete_document(
258    app_state: web::Data<AppState>,
259    user: AuthenticatedUser,
260    path: web::Path<Uuid>,
261) -> impl Responder {
262    let id = path.into_inner();
263
264    match app_state.document_use_cases.delete_document(id).await {
265        Ok(true) => {
266            // Audit log: successful document deletion
267            AuditLogEntry::new(
268                AuditEventType::DocumentDeleted,
269                Some(user.user_id),
270                user.organization_id,
271            )
272            .with_resource("Document", id)
273            .log();
274
275            HttpResponse::NoContent().finish()
276        }
277        Ok(false) => HttpResponse::NotFound().json("Document not found"),
278        Err(e) => {
279            // Audit log: failed document deletion
280            AuditLogEntry::new(
281                AuditEventType::DocumentDeleted,
282                Some(user.user_id),
283                user.organization_id,
284            )
285            .with_resource("Document", id)
286            .with_error(e.clone())
287            .log();
288
289            HttpResponse::InternalServerError().json(e)
290        }
291    }
292}