koprogo_api/application/use_cases/
document_use_cases.rs1use crate::application::dto::{
2 DocumentResponse, LinkDocumentToExpenseRequest, LinkDocumentToMeetingRequest, PageRequest,
3};
4use crate::application::ports::DocumentRepository;
5use crate::domain::entities::{Document, DocumentType};
6use crate::infrastructure::storage::StorageProvider;
7use std::sync::Arc;
8use uuid::Uuid;
9
10pub struct DocumentUseCases {
11 repository: Arc<dyn DocumentRepository>,
12 file_storage: Arc<dyn StorageProvider>,
13}
14
15impl DocumentUseCases {
16 pub fn new(
17 repository: Arc<dyn DocumentRepository>,
18 file_storage: Arc<dyn StorageProvider>,
19 ) -> Self {
20 Self {
21 repository,
22 file_storage,
23 }
24 }
25
26 #[allow(clippy::too_many_arguments)]
28 pub async fn upload_document(
29 &self,
30 organization_id: Uuid,
31 building_id: Uuid,
32 document_type: DocumentType,
33 title: String,
34 description: Option<String>,
35 filename: String,
36 file_content: Vec<u8>,
37 mime_type: String,
38 uploaded_by: Uuid,
39 ) -> Result<DocumentResponse, String> {
40 const MAX_FILE_SIZE: usize = 50 * 1024 * 1024; if file_content.len() > MAX_FILE_SIZE {
43 return Err("File size exceeds maximum limit of 50MB".to_string());
44 }
45
46 if file_content.is_empty() {
47 return Err("File content cannot be empty".to_string());
48 }
49
50 let file_path = self
52 .file_storage
53 .save_file(building_id, &filename, &file_content)
54 .await?;
55
56 let document = Document::new(
58 organization_id,
59 building_id,
60 document_type,
61 title,
62 description,
63 file_path,
64 file_content.len() as i64,
65 mime_type,
66 uploaded_by,
67 )?;
68
69 let created_document = self.repository.create(&document).await?;
71
72 Ok(DocumentResponse::from(created_document))
73 }
74
75 pub async fn get_document(&self, id: Uuid) -> Result<DocumentResponse, String> {
77 match self.repository.find_by_id(id).await? {
78 Some(document) => Ok(DocumentResponse::from(document)),
79 None => Err("Document not found".to_string()),
80 }
81 }
82
83 pub async fn download_document(&self, id: Uuid) -> Result<(Vec<u8>, String, String), String> {
85 let document = match self.repository.find_by_id(id).await? {
86 Some(doc) => doc,
87 None => return Err("Document not found".to_string()),
88 };
89
90 let content = self.file_storage.read_file(&document.file_path).await?;
92
93 let filename = document
95 .file_path
96 .split('/')
97 .next_back()
98 .unwrap_or("download")
99 .to_string();
100
101 Ok((content, document.mime_type, filename))
102 }
103
104 pub async fn list_documents_by_building(
106 &self,
107 building_id: Uuid,
108 ) -> Result<Vec<DocumentResponse>, String> {
109 let documents = self.repository.find_by_building(building_id).await?;
110 Ok(documents.into_iter().map(DocumentResponse::from).collect())
111 }
112
113 pub async fn list_documents_by_meeting(
115 &self,
116 meeting_id: Uuid,
117 ) -> Result<Vec<DocumentResponse>, String> {
118 let documents = self.repository.find_by_meeting(meeting_id).await?;
119 Ok(documents.into_iter().map(DocumentResponse::from).collect())
120 }
121
122 pub async fn list_documents_by_expense(
124 &self,
125 expense_id: Uuid,
126 ) -> Result<Vec<DocumentResponse>, String> {
127 let documents = self.repository.find_by_expense(expense_id).await?;
128 Ok(documents.into_iter().map(DocumentResponse::from).collect())
129 }
130
131 pub async fn list_documents_paginated(
133 &self,
134 page_request: &PageRequest,
135 organization_id: Option<Uuid>,
136 ) -> Result<(Vec<DocumentResponse>, i64), String> {
137 let (documents, total) = self
138 .repository
139 .find_all_paginated(page_request, organization_id)
140 .await?;
141
142 let dtos = documents.into_iter().map(DocumentResponse::from).collect();
143 Ok((dtos, total))
144 }
145
146 pub async fn link_to_meeting(
148 &self,
149 id: Uuid,
150 request: LinkDocumentToMeetingRequest,
151 ) -> Result<DocumentResponse, String> {
152 let mut document = match self.repository.find_by_id(id).await? {
153 Some(doc) => doc,
154 None => return Err("Document not found".to_string()),
155 };
156
157 document.link_to_meeting(request.meeting_id);
158
159 let updated = self.repository.update(&document).await?;
160 Ok(DocumentResponse::from(updated))
161 }
162
163 pub async fn link_to_expense(
165 &self,
166 id: Uuid,
167 request: LinkDocumentToExpenseRequest,
168 ) -> Result<DocumentResponse, String> {
169 let mut document = match self.repository.find_by_id(id).await? {
170 Some(doc) => doc,
171 None => return Err("Document not found".to_string()),
172 };
173
174 document.link_to_expense(request.expense_id);
175
176 let updated = self.repository.update(&document).await?;
177 Ok(DocumentResponse::from(updated))
178 }
179
180 pub async fn delete_document(&self, id: Uuid) -> Result<bool, String> {
182 let document = match self.repository.find_by_id(id).await? {
184 Some(doc) => doc,
185 None => return Err("Document not found".to_string()),
186 };
187
188 let deleted = self.repository.delete(id).await?;
190
191 if deleted {
192 self.file_storage
194 .delete_file(&document.file_path)
195 .await
196 .ok();
197 }
198
199 Ok(deleted)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use crate::application::ports::DocumentRepository;
207 use crate::domain::entities::{Document, DocumentType};
208 use crate::infrastructure::storage::{FileStorage, StorageProvider};
209 use async_trait::async_trait;
210 use std::env;
211 use std::sync::{Arc, Mutex};
212
213 struct MockDocumentRepository {
215 documents: Mutex<Vec<Document>>,
216 }
217
218 impl MockDocumentRepository {
219 fn new() -> Self {
220 Self {
221 documents: Mutex::new(Vec::new()),
222 }
223 }
224 }
225
226 #[async_trait]
227 impl DocumentRepository for MockDocumentRepository {
228 async fn create(&self, document: &Document) -> Result<Document, String> {
229 let mut docs = self.documents.lock().unwrap();
230 docs.push(document.clone());
231 Ok(document.clone())
232 }
233
234 async fn find_by_id(&self, id: Uuid) -> Result<Option<Document>, String> {
235 let docs = self.documents.lock().unwrap();
236 Ok(docs.iter().find(|d| d.id == id).cloned())
237 }
238
239 async fn find_by_building(&self, building_id: Uuid) -> Result<Vec<Document>, String> {
240 let docs = self.documents.lock().unwrap();
241 Ok(docs
242 .iter()
243 .filter(|d| d.building_id == building_id)
244 .cloned()
245 .collect())
246 }
247
248 async fn find_by_meeting(&self, meeting_id: Uuid) -> Result<Vec<Document>, String> {
249 let docs = self.documents.lock().unwrap();
250 Ok(docs
251 .iter()
252 .filter(|d| d.related_meeting_id == Some(meeting_id))
253 .cloned()
254 .collect())
255 }
256
257 async fn find_by_expense(&self, expense_id: Uuid) -> Result<Vec<Document>, String> {
258 let docs = self.documents.lock().unwrap();
259 Ok(docs
260 .iter()
261 .filter(|d| d.related_expense_id == Some(expense_id))
262 .cloned()
263 .collect())
264 }
265
266 async fn update(&self, document: &Document) -> Result<Document, String> {
267 let mut docs = self.documents.lock().unwrap();
268 if let Some(pos) = docs.iter().position(|d| d.id == document.id) {
269 docs[pos] = document.clone();
270 Ok(document.clone())
271 } else {
272 Err("Document not found".to_string())
273 }
274 }
275
276 async fn delete(&self, id: Uuid) -> Result<bool, String> {
277 let mut docs = self.documents.lock().unwrap();
278 if let Some(pos) = docs.iter().position(|d| d.id == id) {
279 docs.remove(pos);
280 Ok(true)
281 } else {
282 Ok(false)
283 }
284 }
285
286 async fn find_all_paginated(
287 &self,
288 page_request: &crate::application::dto::PageRequest,
289 organization_id: Option<Uuid>,
290 ) -> Result<(Vec<Document>, i64), String> {
291 let docs = self.documents.lock().unwrap();
292 let filtered: Vec<Document> = docs
293 .iter()
294 .filter(|d| organization_id.is_none() || Some(d.organization_id) == organization_id)
295 .cloned()
296 .collect();
297
298 let total = filtered.len() as i64;
299 let offset = page_request.offset() as usize;
300 let limit = page_request.limit() as usize;
301
302 let paginated = filtered.into_iter().skip(offset).take(limit).collect();
303
304 Ok((paginated, total))
305 }
306 }
307
308 #[tokio::test]
309 async fn test_upload_document() {
310 let temp_dir = env::temp_dir().join("koprogo_test_upload");
311 let storage: Arc<dyn StorageProvider> = Arc::new(FileStorage::new(&temp_dir).unwrap());
312 let repo = Arc::new(MockDocumentRepository::new());
313 let use_cases = DocumentUseCases::new(repo, storage.clone());
314
315 let org_id = Uuid::new_v4();
316 let building_id = Uuid::new_v4();
317 let uploader_id = Uuid::new_v4();
318 let content = b"Test PDF content".to_vec();
319
320 let result = use_cases
321 .upload_document(
322 org_id,
323 building_id,
324 DocumentType::Invoice,
325 "Test Invoice".to_string(),
326 Some("Test description".to_string()),
327 "invoice.pdf".to_string(),
328 content.clone(),
329 "application/pdf".to_string(),
330 uploader_id,
331 )
332 .await;
333
334 assert!(result.is_ok());
335 let response = result.unwrap();
336 assert_eq!(response.title, "Test Invoice");
337 assert_eq!(response.file_size, content.len() as i64);
338
339 std::fs::remove_dir_all(&temp_dir).ok();
341 }
342
343 #[tokio::test]
344 async fn test_upload_document_too_large() {
345 let temp_dir = env::temp_dir().join("koprogo_test_large");
346 let storage: Arc<dyn StorageProvider> = Arc::new(FileStorage::new(&temp_dir).unwrap());
347 let repo = Arc::new(MockDocumentRepository::new());
348 let use_cases = DocumentUseCases::new(repo, storage.clone());
349
350 let org_id = Uuid::new_v4();
351 let building_id = Uuid::new_v4();
352 let uploader_id = Uuid::new_v4();
353 let large_content = vec![0u8; 51 * 1024 * 1024];
355
356 let result = use_cases
357 .upload_document(
358 org_id,
359 building_id,
360 DocumentType::Invoice,
361 "Large File".to_string(),
362 None,
363 "large.pdf".to_string(),
364 large_content,
365 "application/pdf".to_string(),
366 uploader_id,
367 )
368 .await;
369
370 assert!(result.is_err());
371 assert!(result
372 .unwrap_err()
373 .contains("File size exceeds maximum limit"));
374
375 std::fs::remove_dir_all(&temp_dir).ok();
377 }
378
379 #[tokio::test]
380 async fn test_link_document_to_meeting() {
381 let temp_dir = env::temp_dir().join("koprogo_test_link");
382 let storage: Arc<dyn StorageProvider> = Arc::new(FileStorage::new(&temp_dir).unwrap());
383 let repo = Arc::new(MockDocumentRepository::new());
384 let use_cases = DocumentUseCases::new(repo, storage.clone());
385
386 let org_id = Uuid::new_v4();
387 let building_id = Uuid::new_v4();
388 let uploader_id = Uuid::new_v4();
389 let content = b"Test content".to_vec();
390
391 let doc = use_cases
393 .upload_document(
394 org_id,
395 building_id,
396 DocumentType::MeetingMinutes,
397 "PV AGO".to_string(),
398 None,
399 "pv.pdf".to_string(),
400 content,
401 "application/pdf".to_string(),
402 uploader_id,
403 )
404 .await
405 .unwrap();
406
407 let meeting_id = Uuid::new_v4();
409 let result = use_cases
410 .link_to_meeting(doc.id, LinkDocumentToMeetingRequest { meeting_id })
411 .await;
412
413 assert!(result.is_ok());
414 assert_eq!(result.unwrap().related_meeting_id, Some(meeting_id));
415
416 std::fs::remove_dir_all(&temp_dir).ok();
418 }
419}