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