koprogo_api/application/use_cases/
document_use_cases.rs

1use 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    /// Upload a document with file content
27    #[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        // Validate file size (max 50MB)
41        const MAX_FILE_SIZE: usize = 50 * 1024 * 1024; // 50MB
42        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        // Save file to storage
51        let file_path = self
52            .file_storage
53            .save_file(building_id, &filename, &file_content)
54            .await?;
55
56        // Create document entity
57        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        // Save to database
70        let created_document = self.repository.create(&document).await?;
71
72        Ok(DocumentResponse::from(created_document))
73    }
74
75    /// Get document metadata by ID
76    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    /// Download document file content
84    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        // Read file from storage
91        let content = self.file_storage.read_file(&document.file_path).await?;
92
93        // Extract filename from path (last segment)
94        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    /// List all documents for a building
105    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    /// List all documents for a meeting
114    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    /// List all documents for an expense
123    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    /// List all documents with pagination
132    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    /// Link a document to a meeting
147    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    /// Link a document to an expense
164    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    /// Delete a document (removes from database and file storage)
181    pub async fn delete_document(&self, id: Uuid) -> Result<bool, String> {
182        // Get document to retrieve file path
183        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        // Delete from database first
189        let deleted = self.repository.delete(id).await?;
190
191        if deleted {
192            // Then delete file from storage (ignore errors if file doesn't exist)
193            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    // Mock repository for testing
214    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        // Cleanup
340        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        // Create content larger than 50MB
354        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        // Cleanup
376        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        // Upload document
392        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        // Link to meeting
408        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        // Cleanup
417        std::fs::remove_dir_all(&temp_dir).ok();
418    }
419}