koprogo_api/infrastructure/web/handlers/
document_handlers.rs1use 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#[post("/documents")]
24pub async fn upload_document(
25 app_state: web::Data<AppState>,
26 user: AuthenticatedUser, MultipartForm(form): MultipartForm<UploadForm>,
28) -> impl Responder {
29 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 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 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 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 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 const MAX_FILE_SIZE: usize = 50 * 1024 * 1024; 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 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 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 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 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("/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 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#[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#[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 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#[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 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#[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 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#[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 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#[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 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#[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 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("/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 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 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 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}