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::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    /// Upload a document with file content
24    #[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        // Validate file size (max 50MB)
38        const MAX_FILE_SIZE: usize = 50 * 1024 * 1024; // 50MB
39        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        // Save file to storage
48        let file_path = self
49            .file_storage
50            .save_file(building_id, &filename, &file_content)
51            .await?;
52
53        // Create document entity
54        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        // Save to database
67        let created_document = self.repository.create(&document).await?;
68
69        Ok(DocumentResponse::from(created_document))
70    }
71
72    /// Get document metadata by ID
73    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    /// Download document file content
81    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        // Read file from storage
88        let content = self.file_storage.read_file(&document.file_path).await?;
89
90        // Extract filename from path (last segment)
91        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    /// List all documents for a building
102    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    /// List all documents for a meeting
111    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    /// List all documents with pagination
120    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    /// Link a document to a meeting
135    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    /// Link a document to an expense
152    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    /// Delete a document (removes from database and file storage)
169    pub async fn delete_document(&self, id: Uuid) -> Result<bool, String> {
170        // Get document to retrieve file path
171        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        // Delete from database first
177        let deleted = self.repository.delete(id).await?;
178
179        if deleted {
180            // Then delete file from storage (ignore errors if file doesn't exist)
181            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    // Mock repository for testing
201    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        // Cleanup
318        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        // Create content larger than 50MB
332        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        // Cleanup
354        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        // Upload document
370        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        // Link to meeting
386        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        // Cleanup
395        std::fs::remove_dir_all(&temp_dir).ok();
396    }
397}