diff --git a/src/application/ports/calendar_ports.rs b/src/application/ports/calendar_ports.rs index 46f26bf6..667ead90 100644 --- a/src/application/ports/calendar_ports.rs +++ b/src/application/ports/calendar_ports.rs @@ -106,23 +106,33 @@ pub trait CalendarStoragePort: Send + Sync + 'static { ) -> Result, DomainError>; } -/// Port for calendar use cases +/// Port for calendar use cases. +/// +/// All methods require an explicit `user_id` parameter for authorization. +/// The CalDAV protocol handler extracts the user identity from JWT claims +/// and passes it through. #[async_trait] pub trait CalendarUseCase: Send + Sync + 'static { // Calendar operations async fn create_calendar( &self, calendar: CreateCalendarDto, + user_id: &str, ) -> Result; async fn update_calendar( &self, calendar_id: &str, update: UpdateCalendarDto, + user_id: &str, ) -> Result; - async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError>; - async fn get_calendar(&self, calendar_id: &str) -> Result; - async fn list_my_calendars(&self) -> Result, DomainError>; - async fn list_shared_calendars(&self) -> Result, DomainError>; + async fn delete_calendar(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError>; + async fn get_calendar( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result; + async fn list_my_calendars(&self, user_id: &str) -> Result, DomainError>; + async fn list_shared_calendars(&self, user_id: &str) -> Result, DomainError>; async fn list_public_calendars( &self, limit: Option, @@ -133,90 +143,57 @@ pub trait CalendarUseCase: Send + Sync + 'static { async fn share_calendar( &self, calendar_id: &str, - user_id: &str, + target_user_id: &str, access_level: &str, + caller_user_id: &str, ) -> Result<(), DomainError>; async fn remove_calendar_sharing( &self, calendar_id: &str, - user_id: &str, + target_user_id: &str, + caller_user_id: &str, ) -> Result<(), DomainError>; async fn get_calendar_shares( &self, calendar_id: &str, + user_id: &str, ) -> Result, DomainError>; // Event operations - async fn create_event(&self, event: CreateEventDto) -> Result; + async fn create_event( + &self, + event: CreateEventDto, + user_id: &str, + ) -> Result; async fn create_event_from_ical( &self, event: CreateEventICalDto, + user_id: &str, ) -> Result; async fn update_event( &self, event_id: &str, update: UpdateEventDto, + user_id: &str, + ) -> Result; + async fn delete_event(&self, event_id: &str, user_id: &str) -> Result<(), DomainError>; + async fn get_event( + &self, + event_id: &str, + user_id: &str, ) -> Result; - async fn delete_event(&self, event_id: &str) -> Result<(), DomainError>; - async fn get_event(&self, event_id: &str) -> Result; async fn list_events( &self, calendar_id: &str, limit: Option, offset: Option, + user_id: &str, ) -> Result, DomainError>; async fn get_events_in_range( &self, calendar_id: &str, start: DateTime, end: DateTime, - ) -> Result, DomainError>; - - // ─── User-contextualized variants (for CalDAV protocol handler) ── - async fn create_calendar_for_user( - &self, - calendar: CreateCalendarDto, - user_id: &str, - ) -> Result; - async fn update_calendar_for_user( - &self, - calendar_id: &str, - update: UpdateCalendarDto, - user_id: &str, - ) -> Result; - async fn delete_calendar_for_user( - &self, - calendar_id: &str, - user_id: &str, - ) -> Result<(), DomainError>; - async fn get_calendar_for_user( - &self, - calendar_id: &str, - user_id: &str, - ) -> Result; - async fn list_my_calendars_for_user( - &self, - user_id: &str, - ) -> Result, DomainError>; - async fn list_events_for_user( - &self, - calendar_id: &str, - limit: Option, - offset: Option, user_id: &str, ) -> Result, DomainError>; - async fn get_events_in_range_for_user( - &self, - calendar_id: &str, - start: DateTime, - end: DateTime, - user_id: &str, - ) -> Result, DomainError>; - async fn create_event_from_ical_for_user( - &self, - event: CreateEventICalDto, - user_id: &str, - ) -> Result; - async fn delete_event_for_user(&self, event_id: &str, user_id: &str) - -> Result<(), DomainError>; } diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index e1a698d5..81e634b9 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -138,6 +138,13 @@ pub trait FileManagementUseCase: Send + Sync + 'static { folder_id: Option, ) -> Result; + /// Copies a file to another folder (zero-copy with dedup). + async fn copy_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result; + /// Renames a file async fn rename_file(&self, file_id: &str, new_name: &str) -> Result; diff --git a/src/application/ports/storage_ports.rs b/src/application/ports/storage_ports.rs index 19dad826..47c1302d 100644 --- a/src/application/ports/storage_ports.rs +++ b/src/application/ports/storage_ports.rs @@ -115,6 +115,16 @@ pub trait FileWritePort: Send + Sync + 'static { size: u64, ) -> Result<(File, PathBuf), DomainError>; + /// Copies a file to a (possibly different) folder. + /// + /// With blob-dedup, this only creates a new metadata row and increments + /// the blob reference count — zero disk I/O for the content. + async fn copy_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result; + // ── Trash operations ── /// Moves a file to the trash diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 47aa1ee6..6ba95e1d 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -168,7 +168,7 @@ impl AuthApplicationService { /// Returns whether OIDC is configured and enabled pub fn oidc_enabled(&self) -> bool { let state = self.oidc.read().unwrap(); - state.service.is_some() && state.config.as_ref().is_some_and(|c| c.enabled) + state.service.is_some() && state.config.as_ref().map_or(false, |c| c.enabled) } /// Returns whether password login is disabled (OIDC-only mode) @@ -177,7 +177,7 @@ impl AuthApplicationService { state .config .as_ref() - .is_some_and(|c| c.disable_password_login) + .map_or(false, |c| c.disable_password_login) } /// Returns a clone of the OIDC config if available @@ -664,11 +664,23 @@ impl AuthApplicationService { // Admin quota, capped to available disk space let admin_quota = self.capped_quota(&admin_role); + // Hash the password (same as register / admin_create_user) + let password_hash = self + .password_hasher + .hash_password(&dto.password) + .map_err(|e| { + DomainError::new( + ErrorKind::InternalError, + "User", + format!("Error hashing password: {}", e), + ) + })?; + // Create the new admin user let user = User::new( dto.username.clone(), dto.email.clone(), - dto.password.clone(), + password_hash, admin_role, admin_quota, ) diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index a26e1deb..93c8d5fe 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -129,7 +129,7 @@ impl BatchOperationService { // Acquire semaphore permit let permit = semaphore.acquire().await.unwrap(); - let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await; + let copy_result = mgmt.copy_file(&file_id, target_folder.clone()).await; // Release the permit explicitly (also released on drop) drop(permit); diff --git a/src/application/services/calendar_service.rs b/src/application/services/calendar_service.rs index e854ccd0..d4df1e7c 100644 --- a/src/application/services/calendar_service.rs +++ b/src/application/services/calendar_service.rs @@ -24,13 +24,8 @@ impl CalendarUseCase for CalendarService { async fn create_calendar( &self, calendar: CreateCalendarDto, + user_id: &str, ) -> Result { - // This function requires the current user context which will come from middleware - // For now, we'll use a dummy implementation that needs to be completed - - // In a real implementation, get user_id from current user context - let user_id = "current_user_id"; // This should come from middleware - self.calendar_storage .create_calendar(calendar, user_id) .await @@ -40,20 +35,12 @@ impl CalendarUseCase for CalendarService { &self, calendar_id: &str, update: UpdateCalendarDto, + user_id: &str, ) -> Result { - // In a real implementation, we would: - // 1. Get the current user ID from middleware - // 2. Verify that the user has access to this calendar - // 3. Update the calendar if they have permission - - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access let has_access = self .calendar_storage .check_calendar_access(calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -61,21 +48,16 @@ impl CalendarUseCase for CalendarService { "You don't have permission to update this calendar", )); } - self.calendar_storage .update_calendar(calendar_id, update) .await } - async fn delete_calendar(&self, calendar_id: &str) -> Result<(), DomainError> { - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access + async fn delete_calendar(&self, calendar_id: &str, user_id: &str) -> Result<(), DomainError> { let has_access = self .calendar_storage .check_calendar_access(calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -83,22 +65,19 @@ impl CalendarUseCase for CalendarService { "You don't have permission to delete this calendar", )); } - self.calendar_storage.delete_calendar(calendar_id).await } - async fn get_calendar(&self, calendar_id: &str) -> Result { - let user_id = "current_user_id"; // This should come from middleware - - // Get the calendar + async fn get_calendar( + &self, + calendar_id: &str, + user_id: &str, + ) -> Result { let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - - // Check if user has access or if calendar is public let has_access = self .calendar_storage .check_calendar_access(calendar_id, user_id) .await?; - if !has_access && !calendar.is_public { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -106,19 +85,14 @@ impl CalendarUseCase for CalendarService { "You don't have permission to view this calendar", )); } - Ok(calendar) } - async fn list_my_calendars(&self) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + async fn list_my_calendars(&self, user_id: &str) -> Result, DomainError> { self.calendar_storage.list_calendars_by_owner(user_id).await } - async fn list_shared_calendars(&self) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - + async fn list_shared_calendars(&self, user_id: &str) -> Result, DomainError> { self.calendar_storage .list_calendars_shared_with_user(user_id) .await @@ -131,7 +105,6 @@ impl CalendarUseCase for CalendarService { ) -> Result, DomainError> { let limit = limit.unwrap_or(100); let offset = offset.unwrap_or(0); - self.calendar_storage .list_public_calendars(limit, offset) .await @@ -140,24 +113,18 @@ impl CalendarUseCase for CalendarService { async fn share_calendar( &self, calendar_id: &str, - user_id: &str, + target_user_id: &str, access_level: &str, + caller_user_id: &str, ) -> Result<(), DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - - // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - - // Only the owner can share the calendar - if calendar.owner_id != current_user_id { + if calendar.owner_id != caller_user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", "Only the calendar owner can change sharing settings", )); } - - // Validate access_level match access_level { "read" | "write" | "owner" => {} _ => { @@ -171,66 +138,55 @@ impl CalendarUseCase for CalendarService { )); } } - self.calendar_storage - .share_calendar(calendar_id, user_id, access_level) + .share_calendar(calendar_id, target_user_id, access_level) .await } async fn remove_calendar_sharing( &self, calendar_id: &str, - user_id: &str, + target_user_id: &str, + caller_user_id: &str, ) -> Result<(), DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - - // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - - // Only the owner can change sharing settings - if calendar.owner_id != current_user_id { + if calendar.owner_id != caller_user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", "Only the calendar owner can change sharing settings", )); } - self.calendar_storage - .remove_calendar_sharing(calendar_id, user_id) + .remove_calendar_sharing(calendar_id, target_user_id) .await } async fn get_calendar_shares( &self, calendar_id: &str, + user_id: &str, ) -> Result, DomainError> { - let current_user_id = "current_user_id"; // This should come from middleware - - // Check if current user has access let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - - // Only the owner can view sharing settings - if calendar.owner_id != current_user_id { + if calendar.owner_id != user_id { return Err(DomainError::new( ErrorKind::AccessDenied, "Calendar", "Only the calendar owner can view sharing settings", )); } - self.calendar_storage.get_calendar_shares(calendar_id).await } - async fn create_event(&self, event: CreateEventDto) -> Result { - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access to the calendar + async fn create_event( + &self, + event: CreateEventDto, + user_id: &str, + ) -> Result { let has_access = self .calendar_storage .check_calendar_access(&event.calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -238,22 +194,18 @@ impl CalendarUseCase for CalendarService { "You don't have permission to add events to this calendar", )); } - self.calendar_storage.create_event(event).await } async fn create_event_from_ical( &self, event: CreateEventICalDto, + user_id: &str, ) -> Result { - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access to the calendar let has_access = self .calendar_storage .check_calendar_access(&event.calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -261,7 +213,6 @@ impl CalendarUseCase for CalendarService { "You don't have permission to add events to this calendar", )); } - self.calendar_storage.create_event_from_ical(event).await } @@ -269,18 +220,13 @@ impl CalendarUseCase for CalendarService { &self, event_id: &str, update: UpdateEventDto, + user_id: &str, ) -> Result { - let user_id = "current_user_id"; // This should come from middleware - - // Get the event to find its calendar let event = self.calendar_storage.get_event(event_id).await?; - - // Check if user has access to the calendar let has_access = self .calendar_storage .check_calendar_access(&event.calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -288,22 +234,15 @@ impl CalendarUseCase for CalendarService { "You don't have permission to update events in this calendar", )); } - self.calendar_storage.update_event(event_id, update).await } - async fn delete_event(&self, event_id: &str) -> Result<(), DomainError> { - let user_id = "current_user_id"; // This should come from middleware - - // Get the event to find its calendar + async fn delete_event(&self, event_id: &str, user_id: &str) -> Result<(), DomainError> { let event = self.calendar_storage.get_event(event_id).await?; - - // Check if user has access to the calendar let has_access = self .calendar_storage .check_calendar_access(&event.calendar_id, user_id) .await?; - if !has_access { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -311,28 +250,23 @@ impl CalendarUseCase for CalendarService { "You don't have permission to delete events in this calendar", )); } - self.calendar_storage.delete_event(event_id).await } - async fn get_event(&self, event_id: &str) -> Result { - let user_id = "current_user_id"; // This should come from middleware - - // Get the event + async fn get_event( + &self, + event_id: &str, + user_id: &str, + ) -> Result { let event = self.calendar_storage.get_event(event_id).await?; - - // Check if user has access to the calendar let has_access = self .calendar_storage .check_calendar_access(&event.calendar_id, user_id) .await?; - - // Check if calendar is public let calendar = self .calendar_storage .get_calendar(&event.calendar_id) .await?; - if !has_access && !calendar.is_public { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -340,7 +274,6 @@ impl CalendarUseCase for CalendarService { "You don't have permission to view events in this calendar", )); } - Ok(event) } @@ -349,18 +282,13 @@ impl CalendarUseCase for CalendarService { calendar_id: &str, limit: Option, offset: Option, + user_id: &str, ) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access to the calendar let has_access = self .calendar_storage .check_calendar_access(calendar_id, user_id) .await?; - - // Check if calendar is public let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { return Err(DomainError::new( ErrorKind::AccessDenied, @@ -368,12 +296,9 @@ impl CalendarUseCase for CalendarService { "You don't have permission to view events in this calendar", )); } - - // Use pagination if provided if limit.is_some() || offset.is_some() { let limit = limit.unwrap_or(100); let offset = offset.unwrap_or(0); - self.calendar_storage .list_events_by_calendar_paginated(calendar_id, limit, offset) .await @@ -389,148 +314,6 @@ impl CalendarUseCase for CalendarService { calendar_id: &str, start: DateTime, end: DateTime, - ) -> Result, DomainError> { - let user_id = "current_user_id"; // This should come from middleware - - // Check if user has access to the calendar - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - - // Check if calendar is public - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); - } - - self.calendar_storage - .get_events_in_time_range(calendar_id, &start, &end) - .await - } - - // ─── User-contextualized variants (for CalDAV protocol handler) ── - - async fn create_calendar_for_user( - &self, - calendar: CreateCalendarDto, - user_id: &str, - ) -> Result { - self.calendar_storage - .create_calendar(calendar, user_id) - .await - } - - async fn update_calendar_for_user( - &self, - calendar_id: &str, - update: UpdateCalendarDto, - user_id: &str, - ) -> Result { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to update this calendar", - )); - } - self.calendar_storage - .update_calendar(calendar_id, update) - .await - } - - async fn delete_calendar_for_user( - &self, - calendar_id: &str, - user_id: &str, - ) -> Result<(), DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to delete this calendar", - )); - } - self.calendar_storage.delete_calendar(calendar_id).await - } - - async fn get_calendar_for_user( - &self, - calendar_id: &str, - user_id: &str, - ) -> Result { - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view this calendar", - )); - } - Ok(calendar) - } - - async fn list_my_calendars_for_user( - &self, - user_id: &str, - ) -> Result, DomainError> { - self.calendar_storage.list_calendars_by_owner(user_id).await - } - - async fn list_events_for_user( - &self, - calendar_id: &str, - limit: Option, - offset: Option, - user_id: &str, - ) -> Result, DomainError> { - let has_access = self - .calendar_storage - .check_calendar_access(calendar_id, user_id) - .await?; - let calendar = self.calendar_storage.get_calendar(calendar_id).await?; - if !has_access && !calendar.is_public { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to view events in this calendar", - )); - } - if limit.is_some() || offset.is_some() { - let limit = limit.unwrap_or(100); - let offset = offset.unwrap_or(0); - self.calendar_storage - .list_events_by_calendar_paginated(calendar_id, limit, offset) - .await - } else { - self.calendar_storage - .list_events_by_calendar(calendar_id) - .await - } - } - - async fn get_events_in_range_for_user( - &self, - calendar_id: &str, - start: DateTime, - end: DateTime, user_id: &str, ) -> Result, DomainError> { let has_access = self @@ -549,43 +332,4 @@ impl CalendarUseCase for CalendarService { .get_events_in_time_range(calendar_id, &start, &end) .await } - - async fn create_event_from_ical_for_user( - &self, - event: CreateEventICalDto, - user_id: &str, - ) -> Result { - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) - .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to add events to this calendar", - )); - } - self.calendar_storage.create_event_from_ical(event).await - } - - async fn delete_event_for_user( - &self, - event_id: &str, - user_id: &str, - ) -> Result<(), DomainError> { - let event = self.calendar_storage.get_event(event_id).await?; - let has_access = self - .calendar_storage - .check_calendar_access(&event.calendar_id, user_id) - .await?; - if !has_access { - return Err(DomainError::new( - ErrorKind::AccessDenied, - "Calendar", - "You don't have permission to delete events in this calendar", - )); - } - self.calendar_storage.delete_event(event_id).await - } } diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 8cc689cf..81ecd46c 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -121,6 +121,35 @@ impl FileManagementUseCase for FileManagementService { Ok(FileDto::from(moved_file)) } + async fn copy_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result { + info!( + "Copying file with ID: {} to folder: {:?}", + file_id, target_folder_id + ); + + let copied_file = self + .file_repository + .copy_file(file_id, target_folder_id) + .await + .map_err(|e| { + error!("Error copying file (ID: {}): {}", file_id, e); + e + })?; + + info!( + "File copied successfully: {} (ID: {}) to folder: {:?}", + copied_file.name(), + copied_file.id(), + copied_file.folder_id() + ); + + Ok(FileDto::from(copied_file)) + } + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { info!("Renaming file with ID: {} to \"{}\"", file_id, new_name); diff --git a/src/common/di.rs b/src/common/di.rs index 6c91d417..ba34dccf 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -1,54 +1,58 @@ +use sqlx::PgPool; use std::path::PathBuf; use std::sync::Arc; -use sqlx::PgPool; -use crate::application::services::auth_application_service::AuthApplicationService; use crate::application::services::admin_settings_service::AdminSettingsService; +use crate::application::services::auth_application_service::AuthApplicationService; -use crate::infrastructure::services::path_service::PathService; -use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository; -use crate::infrastructure::repositories::pg::{ - FolderDbRepository, FileBlobReadRepository, FileBlobWriteRepository, TrashDbRepository, -}; -use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; -use crate::infrastructure::services::file_content_cache::{FileContentCache, FileContentCacheConfig}; -use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; -use crate::application::services::folder_service::FolderService; -use crate::application::services::i18n_application_service::I18nApplicationService; -use crate::application::services::trash_service::TrashService; -use crate::application::services::search_service::SearchService; -use crate::application::services::share_service::ShareService; -use crate::application::services::favorites_service::FavoritesService; -use crate::application::services::recent_service::RecentService; -use crate::application::ports::trash_ports::TrashUseCase; -use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; -use crate::application::ports::outbound::FolderStoragePort; -use crate::application::ports::favorites_ports::FavoritesUseCase; -use crate::application::ports::recent_ports::RecentItemsUseCase; -use crate::application::ports::file_ports::{FileUploadUseCase, FileRetrievalUseCase, FileManagementUseCase, FileUseCaseFactory}; -use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; -use crate::application::services::{FileUploadService, FileRetrievalService, FileManagementService, AppFileUseCaseFactory}; -use crate::common::errors::DomainError; -use crate::domain::services::i18n_service::I18nService; -use crate::common::config::AppConfig; use crate::application::ports::cache_ports::ContentCachePort; -use crate::application::ports::thumbnail_ports::ThumbnailPort; -use crate::application::ports::transcode_ports::ImageTranscodePort; -use crate::application::ports::dedup_ports::DedupPort; use crate::application::ports::chunked_upload_ports::ChunkedUploadPort; use crate::application::ports::compression_ports::CompressionPort; +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::favorites_ports::FavoritesUseCase; +use crate::application::ports::file_ports::{ + FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase, FileUseCaseFactory, +}; +use crate::application::ports::inbound::{FolderUseCase, SearchUseCase}; +use crate::application::ports::outbound::FolderStoragePort; +use crate::application::ports::recent_ports::RecentItemsUseCase; +use crate::application::ports::storage_ports::{FileReadPort, FileWritePort}; +use crate::application::ports::thumbnail_ports::ThumbnailPort; +use crate::application::ports::transcode_ports::ImageTranscodePort; +use crate::application::ports::trash_ports::TrashUseCase; use crate::application::ports::zip_ports::ZipPort; +use crate::application::services::favorites_service::FavoritesService; +use crate::application::services::folder_service::FolderService; +use crate::application::services::i18n_application_service::I18nApplicationService; +use crate::application::services::recent_service::RecentService; +use crate::application::services::search_service::SearchService; +use crate::application::services::share_service::ShareService; +use crate::application::services::trash_service::TrashService; +use crate::application::services::{ + AppFileUseCaseFactory, FileManagementService, FileRetrievalService, FileUploadService, +}; +use crate::common::config::AppConfig; +use crate::common::errors::DomainError; +use crate::domain::services::i18n_service::I18nService; +use crate::infrastructure::repositories::pg::{ + FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, TrashDbRepository, +}; +use crate::infrastructure::repositories::share_fs_repository::ShareFsRepository; +use crate::infrastructure::services::file_content_cache::{ + FileContentCache, FileContentCacheConfig, +}; +use crate::infrastructure::services::file_system_i18n_service::FileSystemI18nService; +use crate::infrastructure::services::path_service::PathService; +use crate::infrastructure::services::trash_cleanup_service::TrashCleanupService; use crate::common::stubs::{ - StubZipPort, StubCompressionPort, - StubFileReadPort, StubFileWritePort, StubFolderStoragePort, - StubI18nService, StubFolderUseCase, StubFileUploadUseCase, - StubFileRetrievalUseCase, StubFileManagementUseCase, StubFileUseCaseFactory, - StubSearchUseCase, StubDedupPort, + StubCompressionPort, StubDedupPort, StubFileManagementUseCase, StubFileReadPort, + StubFileRetrievalUseCase, StubFileUploadUseCase, StubFileUseCaseFactory, StubFileWritePort, + StubFolderStoragePort, StubFolderUseCase, StubI18nService, StubSearchUseCase, StubZipPort, }; /// Factory for the different application components -/// +/// /// This factory centralizes the creation of all application services, /// ensuring the correct initialization order and resolving circular dependencies. pub struct AppServiceFactory { @@ -66,7 +70,7 @@ impl AppServiceFactory { config: AppConfig::default(), } } - + /// Creates a new service factory with custom configuration pub fn with_config(storage_path: PathBuf, locales_path: PathBuf, config: AppConfig) -> Self { Self { @@ -75,72 +79,82 @@ impl AppServiceFactory { config, } } - + /// Gets the configuration pub fn config(&self) -> &AppConfig { &self.config } - + /// Gets the storage path pub fn storage_path(&self) -> &PathBuf { &self.storage_path } - + /// Initializes the core system services. /// /// Requires a `PgPool` because `DedupService` stores its index in PostgreSQL. - pub async fn create_core_services(&self, db_pool: &Arc) -> Result { + pub async fn create_core_services( + &self, + db_pool: &Arc, + ) -> Result { // Path service (still needed for blob storage root + thumbnails) let path_service = Arc::new(PathService::new(self.storage_path.clone())); - + // File content cache for ultra-fast file serving (hot files in RAM) let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig { - max_file_size: 10 * 1024 * 1024, // 10MB max per file - max_total_size: 512 * 1024 * 1024, // 512MB total cache - max_entries: 10000, // Up to 10k files + max_file_size: 10 * 1024 * 1024, // 10MB max per file + max_total_size: 512 * 1024 * 1024, // 512MB total cache + max_entries: 10000, // Up to 10k files })); tracing::info!("FileContentCache initialized: max 10MB/file, 512MB total, 10k entries"); - + // Thumbnail service for thumbnail generation let thumbnail_service = Arc::new( crate::infrastructure::services::thumbnail_service::ThumbnailService::new( &self.storage_path, - 5000, // max 5000 thumbnails in cache - 100 * 1024 * 1024, // max 100MB cache - ) + 5000, // max 5000 thumbnails in cache + 100 * 1024 * 1024, // max 100MB cache + ), ); // Initialize thumbnail directories thumbnail_service.initialize().await?; - + // Chunked upload service for large files (>10MB) let chunked_temp_dir = std::path::PathBuf::from(&self.storage_path).join(".uploads"); let chunked_upload_service = Arc::new( - crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new(chunked_temp_dir) + crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( + chunked_temp_dir, + ), ); - + // Image transcoding service for automatic WebP conversion let image_transcode_service = Arc::new( crate::infrastructure::services::image_transcode_service::ImageTranscodeService::new( &self.storage_path, - 2000, // max 2000 transcoded images in cache - 50 * 1024 * 1024, // max 50MB in-memory cache - ) + 2000, // max 2000 transcoded images in cache + 50 * 1024 * 1024, // max 50MB in-memory cache + ), ); image_transcode_service.initialize().await?; - + // Deduplication service — PRIMARY blob storage engine (PostgreSQL-backed index) let dedup_service = Arc::new( - crate::infrastructure::services::dedup_service::DedupService::new(&self.storage_path, db_pool.clone()) + crate::infrastructure::services::dedup_service::DedupService::new( + &self.storage_path, + db_pool.clone(), + ), ); dedup_service.initialize().await?; - + // Compression service (gzip) let compression_service: Arc = Arc::new( - crate::infrastructure::services::compression_service::GzipCompressionService::new() + crate::infrastructure::services::compression_service::GzipCompressionService::new(), ); - - tracing::info!("Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage), compression"); - + + tracing::info!( + "Core services initialized: path service, file content cache, thumbnails, chunked upload, image transcode, dedup (PRIMARY blob storage), compression" + ); + Ok(CoreServices { path_service, file_content_cache, @@ -149,49 +163,56 @@ impl AppServiceFactory { image_transcode_service, dedup_service, compression_service, - zip_service: Arc::new(StubZipPort), // Placeholder - replaced after app services init + zip_service: Arc::new(StubZipPort), // Placeholder - replaced after app services init config: self.config.clone(), }) } - + /// Initializes the repository services (blob-storage model). /// /// Requires a PgPool since all metadata lives in PostgreSQL. - pub fn create_repository_services(&self, core: &CoreServices, db_pool: &Arc) -> RepositoryServices { + pub fn create_repository_services( + &self, + core: &CoreServices, + db_pool: &Arc, + ) -> RepositoryServices { // Folder repository — PostgreSQL-backed virtual folders let folder_repo_concrete = Arc::new(FolderDbRepository::new(db_pool.clone())); let folder_repository: Arc = folder_repo_concrete.clone(); - + // File repositories — PostgreSQL metadata + blob content via DedupService let file_read_repository: Arc = Arc::new(FileBlobReadRepository::new( db_pool.clone(), core.dedup_service.clone(), folder_repo_concrete.clone(), )); - + let file_write_repository: Arc = Arc::new(FileBlobWriteRepository::new( db_pool.clone(), core.dedup_service.clone(), folder_repo_concrete.clone(), )); - + // I18n repository - let i18n_repository = Arc::new(FileSystemI18nService::new( - self.locales_path.clone() - )); - + let i18n_repository = Arc::new(FileSystemI18nService::new(self.locales_path.clone())); + // Trash repository — reads soft-delete flags from storage.files/folders let trash_repository = if core.config.features.enable_trash { Some(Arc::new(TrashDbRepository::new( db_pool.clone(), core.config.storage.trash_retention_days, - )) as Arc) + )) + as Arc< + dyn crate::domain::repositories::trash_repository::TrashRepository, + >) } else { None }; - - tracing::info!("Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)"); - + + tracing::info!( + "Repository services initialized with 100% blob storage model (PG metadata + DedupService blobs)" + ); + RepositoryServices { folder_repository, folder_repo_concrete, @@ -201,7 +222,7 @@ impl AppServiceFactory { trash_repository, } } - + /// Initializes the application services pub fn create_application_services( &self, @@ -210,23 +231,21 @@ impl AppServiceFactory { trash_service: Option>, ) -> ApplicationServices { // Main services - let folder_service = Arc::new(FolderService::new( - repos.folder_repository.clone() - )); - + let folder_service = Arc::new(FolderService::new(repos.folder_repository.clone())); + // Refactored services with all infrastructure ports // In blob model, dedup is handled by the repository — no separate write-behind needed let file_upload_service = Arc::new(FileUploadService::new_with_read( repos.file_write_repository.clone(), repos.file_read_repository.clone(), )); - + let file_retrieval_service = Arc::new(FileRetrievalService::new_with_cache( repos.file_read_repository.clone(), core.file_content_cache.clone(), core.image_transcode_service.clone(), )); - + // FileManagementService with dedup and trash let file_management_service = Arc::new(FileManagementService::new_full( repos.file_write_repository.clone(), @@ -234,26 +253,24 @@ impl AppServiceFactory { trash_service.clone(), core.dedup_service.clone(), )); - + let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new( repos.file_read_repository.clone(), - repos.file_write_repository.clone() + repos.file_write_repository.clone(), )); - - let i18n_service = Arc::new(I18nApplicationService::new( - repos.i18n_repository.clone() - )); - + + let i18n_service = Arc::new(I18nApplicationService::new(repos.i18n_repository.clone())); + // Search service with cache let search_service: Option> = Some(Arc::new(SearchService::new( repos.file_read_repository.clone(), repos.folder_repository.clone(), - 300, // Cache TTL in seconds (5 minutes) + 300, // Cache TTL in seconds (5 minutes) 1000, // Maximum cache entries ))); - + tracing::info!("Application services initialized"); - + ApplicationServices { // Concrete types for handlers that need them folder_service_concrete: folder_service.clone(), @@ -266,12 +283,12 @@ impl AppServiceFactory { i18n_service, trash_service, // Already set via parameter search_service, - share_service: None, // Configured later with create_share_service + share_service: None, // Configured later with create_share_service favorites_service: None, // Configured later with create_favorites_service - recent_service: None, // Configured later with create_recent_service + recent_service: None, // Configured later with create_recent_service } } - + /// Creates the trash service pub async fn create_trash_service( &self, @@ -281,9 +298,9 @@ impl AppServiceFactory { tracing::info!("Trash service is disabled in configuration"); return None; } - + let trash_repo = repos.trash_repository.as_ref()?; - + // Wire ports directly to TrashService — no adapter layer needed let service = Arc::new(TrashService::new( trash_repo.clone(), @@ -292,20 +309,20 @@ impl AppServiceFactory { repos.folder_repository.clone(), self.config.storage.trash_retention_days, )); - + // Initialize cleanup service let cleanup_service = TrashCleanupService::new( service.clone(), trash_repo.clone(), 24, // Run cleanup every 24 hours ); - + cleanup_service.start_cleanup_job().await; tracing::info!("Trash service initialized with daily cleanup schedule"); - + Some(service as Arc) } - + /// Creates the sharing service pub fn create_share_service( &self, @@ -315,11 +332,9 @@ impl AppServiceFactory { tracing::info!("File sharing service is disabled in configuration"); return None; } - - let share_repository = Arc::new(ShareFsRepository::new( - Arc::new(self.config.clone()) - )); - + + let share_repository = Arc::new(ShareFsRepository::new(Arc::new(self.config.clone()))); + // Build a password hasher for share password verification let password_hasher: Arc = Arc::new(crate::infrastructure::services::password_hasher::Argon2PasswordHasher::new()); @@ -331,44 +346,37 @@ impl AppServiceFactory { repos.folder_repository.clone(), password_hasher, )); - + tracing::info!("File sharing service initialized"); Some(service) } - + /// Creates the favorites service (requires database) - pub fn create_favorites_service( - &self, - db_pool: &Arc, - ) -> Arc { + pub fn create_favorites_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( - crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::FavoritesPgRepository::new(db_pool.clone()), ); let service = Arc::new(FavoritesService::new(repo)); tracing::info!("Favorites service initialized"); service } - + /// Creates the recent items service (requires database) - pub fn create_recent_service( - &self, - db_pool: &Arc, - ) -> Arc { + pub fn create_recent_service(&self, db_pool: &Arc) -> Arc { let repo = Arc::new( - crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::RecentItemsPgRepository::new(db_pool.clone()), ); let service = Arc::new(RecentService::new( - repo, - 50 // Maximum recent items per user + repo, 50, // Maximum recent items per user )); tracing::info!("Recent items service initialized"); service } - + /// Preloads translations pub async fn preload_translations(&self, i18n_service: &I18nApplicationService) { use crate::domain::services::i18n_service::Locale; - + if let Err(e) = i18n_service.load_translations(Locale::English).await { tracing::warn!("Failed to load English translations: {}", e); } @@ -394,13 +402,13 @@ impl AppServiceFactory { db_pool: &Arc, ) -> Arc { let user_repository = Arc::new( - crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()) + crate::infrastructure::repositories::pg::UserPgRepository::new(db_pool.clone()), ); let service = Arc::new( crate::application::services::storage_usage_service::StorageUsageService::new( repos.file_read_repository.clone(), user_repository, - ) + ), ); tracing::info!("Storage usage service initialized"); service @@ -415,7 +423,10 @@ impl AppServiceFactory { ) -> Result { // Database is REQUIRED in 100% blob storage model let pool = db_pool.clone().ok_or_else(|| { - DomainError::internal_error("Database", "PostgreSQL database is required for blob storage model") + DomainError::internal_error( + "Database", + "PostgreSQL database is required for blob storage model", + ) })?; // 1. Core services (PgPool needed for DedupService index) @@ -437,7 +448,9 @@ impl AppServiceFactory { // 6. Database-dependent services (PgPool always available in blob model) let favorites_service: Option>; let recent_service: Option>; - let storage_usage_service: Option>; + let storage_usage_service: Option< + Arc, + >; let mut auth_services: Option = None; { @@ -457,7 +470,9 @@ impl AppServiceFactory { &self.config, pool.clone(), Some(apps.folder_service_concrete.clone()), - ).await { + ) + .await + { Ok(services) => { tracing::info!("Authentication services initialized successfully"); auth_services = Some(services); @@ -477,7 +492,7 @@ impl AppServiceFactory { crate::infrastructure::services::zip_service::ZipService::new( apps.file_retrieval_service.clone(), apps.folder_service.clone(), - ) + ), ); let mut core = core; core.zip_service = zip_service; @@ -501,11 +516,11 @@ impl AppServiceFactory { addressbook_use_case: None, contact_use_case: None, }; - + // 9b. Wire admin settings service when auth is available if let Some(auth_svc) = &app_state.auth_service { let settings_repo = Arc::new( - crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()) + crate::infrastructure::repositories::pg::SettingsPgRepository::new(pool.clone()), ); let server_base_url = self.config.base_url(); @@ -521,34 +536,50 @@ impl AppServiceFactory { // Hot-reload OIDC from DB settings if configured match admin_svc.load_effective_oidc_config().await { - Ok(eff) if eff.enabled && !eff.issuer_url.is_empty() - && !eff.client_id.is_empty() && !eff.client_secret.is_empty() => + Ok(eff) + if eff.enabled + && !eff.issuer_url.is_empty() + && !eff.client_id.is_empty() + && !eff.client_secret.is_empty() => { let oidc_svc = Arc::new( - crate::infrastructure::services::oidc_service::OidcService::new(eff.clone()) + crate::infrastructure::services::oidc_service::OidcService::new( + eff.clone(), + ), ); auth_svc.auth_application_service.reload_oidc(oidc_svc, eff); tracing::info!("OIDC config loaded from admin settings (database)"); } Ok(_) => { - tracing::info!("No active OIDC config in admin settings — using env vars or defaults"); + tracing::info!( + "No active OIDC config in admin settings — using env vars or defaults" + ); } Err(e) => { - tracing::warn!("Failed to load OIDC settings from database (table may not exist yet): {}", e); + tracing::warn!( + "Failed to load OIDC settings from database (table may not exist yet): {}", + e + ); } } app_state.admin_settings_service = Some(admin_svc); } - + // 10. Wire CalDAV/CardDAV services { // CalDAV - let calendar_repo: Arc = Arc::new( - crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()) + let calendar_repo: Arc< + dyn crate::domain::repositories::calendar_repository::CalendarRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::CalendarPgRepository::new(pool.clone()), ); - let event_repo: Arc = Arc::new( - crate::infrastructure::repositories::pg::CalendarEventPgRepository::new(pool.clone()) + let event_repo: Arc< + dyn crate::domain::repositories::calendar_event_repository::CalendarEventRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::CalendarEventPgRepository::new( + pool.clone(), + ), ); let calendar_storage = Arc::new( crate::infrastructure::adapters::calendar_storage_adapter::CalendarStorageAdapter::new( @@ -557,19 +588,32 @@ impl AppServiceFactory { ) ); let calendar_service = Arc::new( - crate::application::services::calendar_service::CalendarService::new(calendar_storage) + crate::application::services::calendar_service::CalendarService::new( + calendar_storage, + ), ); - app_state.calendar_use_case = Some(calendar_service as Arc); - + app_state.calendar_use_case = Some( + calendar_service + as Arc, + ); + // CardDAV - let address_book_repo: Arc = Arc::new( - crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()) + let address_book_repo: Arc< + dyn crate::domain::repositories::address_book_repository::AddressBookRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::AddressBookPgRepository::new(pool.clone()), ); - let contact_repo: Arc = Arc::new( - crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()) + let contact_repo: Arc< + dyn crate::domain::repositories::contact_repository::ContactRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::ContactPgRepository::new(pool.clone()), ); - let group_repo: Arc = Arc::new( - crate::infrastructure::repositories::pg::ContactGroupPgRepository::new(pool.clone()) + let group_repo: Arc< + dyn crate::domain::repositories::contact_repository::ContactGroupRepository, + > = Arc::new( + crate::infrastructure::repositories::pg::ContactGroupPgRepository::new( + pool.clone(), + ), ); let contact_storage = Arc::new( crate::infrastructure::adapters::contact_storage_adapter::ContactStorageAdapter::new( @@ -578,9 +622,13 @@ impl AppServiceFactory { group_repo, ) ); - app_state.addressbook_use_case = Some(contact_storage.clone() as Arc); - app_state.contact_use_case = Some(contact_storage as Arc); - + app_state.addressbook_use_case = Some(contact_storage.clone() + as Arc); + app_state.contact_use_case = Some( + contact_storage + as Arc, + ); + tracing::info!("CalDAV and CardDAV services initialized with PostgreSQL repositories"); } @@ -610,7 +658,8 @@ pub struct RepositoryServices { pub file_read_repository: Arc, pub file_write_repository: Arc, pub i18n_repository: Arc, - pub trash_repository: Option>, + pub trash_repository: + Option>, } /// Container for application services @@ -652,11 +701,14 @@ pub struct AppState { pub share_service: Option>, pub favorites_service: Option>, pub recent_service: Option>, - pub storage_usage_service: Option>, + pub storage_usage_service: + Option>, pub calendar_service: Option>, pub contact_service: Option>, - pub calendar_use_case: Option>, - pub addressbook_use_case: Option>, + pub calendar_use_case: + Option>, + pub addressbook_use_case: + Option>, pub contact_use_case: Option>, } @@ -668,16 +720,22 @@ impl Default for AppState { let config = crate::common::config::AppConfig::default(); let path_service = Arc::new( crate::infrastructure::services::path_service::PathService::new( - std::path::PathBuf::from("./storage") - ) + std::path::PathBuf::from("./storage"), + ), ); - let i18n_repository = Arc::new(StubI18nService) as Arc; - let folder_service = Arc::new(StubFolderUseCase) as Arc; - let file_upload_service = Arc::new(StubFileUploadUseCase) as Arc; - let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) as Arc; - let file_management_service = Arc::new(StubFileManagementUseCase) as Arc; - let file_use_case_factory = Arc::new(StubFileUseCaseFactory) as Arc; + let i18n_repository = Arc::new(StubI18nService) + as Arc; + let folder_service = Arc::new(StubFolderUseCase) + as Arc; + let file_upload_service = Arc::new(StubFileUploadUseCase) + as Arc; + let file_retrieval_service = Arc::new(StubFileRetrievalUseCase) + as Arc; + let file_management_service = Arc::new(StubFileManagementUseCase) + as Arc; + let file_use_case_factory = Arc::new(StubFileUseCaseFactory) + as Arc; // Create file content cache for stub let file_content_cache = Arc::new(FileContentCache::new(FileContentCacheConfig::default())); @@ -688,14 +746,14 @@ impl Default for AppState { &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, - ) + ), ); // Create dummy chunked upload service let dummy_chunked_upload_service: Arc = Arc::new( crate::infrastructure::services::chunked_upload_service::ChunkedUploadService::new( - std::path::PathBuf::from("./storage/.uploads") - ) + std::path::PathBuf::from("./storage/.uploads"), + ), ); // Create dummy image transcode service @@ -704,7 +762,7 @@ impl Default for AppState { &std::path::PathBuf::from("./storage"), 100, 10 * 1024 * 1024, - ) + ), ); // Stub dedup service (Default is only used for routing stubs, never for real I/O) @@ -729,22 +787,28 @@ impl Default for AppState { // Repository services using stubs let repository_services = RepositoryServices { - folder_repository: Arc::new(StubFolderStoragePort) as Arc, + folder_repository: Arc::new(StubFolderStoragePort) + as Arc, folder_repo_concrete: dummy_folder_repo_concrete, - file_read_repository: Arc::new(StubFileReadPort) as Arc, - file_write_repository: Arc::new(StubFileWritePort) as Arc, + file_read_repository: Arc::new(StubFileReadPort) + as Arc, + file_write_repository: Arc::new(StubFileWritePort) + as Arc, i18n_repository, trash_repository: None, }; // Dummy concrete services for compatibility - let dummy_folder_storage = Arc::new(StubFolderStoragePort) as Arc; + let dummy_folder_storage = Arc::new(StubFolderStoragePort) + as Arc; let folder_service_concrete = Arc::new(FolderService::new(dummy_folder_storage)); // Dummy I18nApplicationService - let dummy_i18n_app_service = crate::application::services::i18n_application_service::I18nApplicationService::new( - Arc::new(StubI18nService) as Arc - ); + let dummy_i18n_app_service = + crate::application::services::i18n_application_service::I18nApplicationService::new( + Arc::new(StubI18nService) + as Arc, + ); // Application services using stubs let application_services = ApplicationServices { @@ -756,7 +820,8 @@ impl Default for AppState { file_use_case_factory, i18n_service: Arc::new(dummy_i18n_app_service), trash_service: None, - search_service: Some(Arc::new(StubSearchUseCase) as Arc), + search_service: Some(Arc::new(StubSearchUseCase) + as Arc), share_service: None, favorites_service: None, recent_service: None, @@ -808,12 +873,12 @@ impl AppState { contact_use_case: None, } } - + pub fn with_database(mut self, db_pool: Arc) -> Self { self.db_pool = Some(db_pool); self } - + /// Creates a minimal AppState for route construction. /// /// Uses `Default` stubs for infrastructure services, then overlays the real @@ -821,11 +886,15 @@ impl AppState { /// This keeps `routes.rs` free of any `crate::infrastructure` references. pub fn for_routing( folder_service: Arc, - file_retrieval_service: Arc, + file_retrieval_service: Arc< + dyn crate::application::ports::file_ports::FileRetrievalUseCase, + >, file_upload_service: Arc, file_management_service: Arc, folder_use_case: Arc, - i18n_service: Option>, + i18n_service: Option< + Arc, + >, trash_service: Option>, search_service: Option>, share_service: Option>, @@ -833,98 +902,121 @@ impl AppState { recent_service: Option>, ) -> Self { let mut state = Self::default(); - + // Override application services with real ones state.applications.folder_service_concrete = folder_service.clone(); state.applications.folder_service = folder_use_case; state.applications.file_upload_service = file_upload_service; state.applications.file_retrieval_service = file_retrieval_service.clone(); state.applications.file_management_service = file_management_service; - + if let Some(i18n) = i18n_service { state.applications.i18n_service = i18n; } - + state.applications.trash_service = trash_service.clone(); state.applications.search_service = search_service.clone(); state.applications.share_service = share_service.clone(); state.applications.favorites_service = favorites_service.clone(); state.applications.recent_service = recent_service.clone(); - + // Also set top-level optional services state.trash_service = trash_service; state.share_service = share_service; state.favorites_service = favorites_service; state.recent_service = recent_service; - + // Create real ZipService with the actual file/folder services state.core.zip_service = Arc::new( crate::infrastructure::services::zip_service::ZipService::new( - file_retrieval_service as Arc, - folder_service.clone() as Arc, - ) + file_retrieval_service + as Arc, + folder_service.clone() + as Arc, + ), ); - + state } - + pub fn with_auth_services(mut self, auth_services: AuthServices) -> Self { self.auth_service = Some(auth_services); self } - + pub fn with_trash_service(mut self, trash_service: Arc) -> Self { self.trash_service = Some(trash_service); self } - - pub fn with_share_service(mut self, share_service: Arc) -> Self { + + pub fn with_share_service( + mut self, + share_service: Arc, + ) -> Self { self.share_service = Some(share_service); self } - + pub fn with_favorites_service(mut self, favorites_service: Arc) -> Self { self.favorites_service = Some(favorites_service); self } - + pub fn with_recent_service(mut self, recent_service: Arc) -> Self { self.recent_service = Some(recent_service); self } - - pub fn with_storage_usage_service(mut self, storage_usage_service: Arc) -> Self { + + pub fn with_storage_usage_service( + mut self, + storage_usage_service: Arc, + ) -> Self { self.storage_usage_service = Some(storage_usage_service); self } - - pub fn with_calendar_service(mut self, calendar_service: Arc) -> Self { + + pub fn with_calendar_service( + mut self, + calendar_service: Arc, + ) -> Self { self.calendar_service = Some(calendar_service); self } - - pub fn with_contact_service(mut self, contact_service: Arc) -> Self { + + pub fn with_contact_service( + mut self, + contact_service: Arc, + ) -> Self { self.contact_service = Some(contact_service); self } - - pub fn with_calendar_use_case(mut self, calendar_use_case: Arc) -> Self { + + pub fn with_calendar_use_case( + mut self, + calendar_use_case: Arc, + ) -> Self { self.calendar_use_case = Some(calendar_use_case); self } - - pub fn with_addressbook_use_case(mut self, addressbook_use_case: Arc) -> Self { + + pub fn with_addressbook_use_case( + mut self, + addressbook_use_case: Arc, + ) -> Self { self.addressbook_use_case = Some(addressbook_use_case); self } - - pub fn with_contact_use_case(mut self, contact_use_case: Arc) -> Self { + + pub fn with_contact_use_case( + mut self, + contact_use_case: Arc, + ) -> Self { self.contact_use_case = Some(contact_use_case); self } - + pub fn with_zip_service(mut self, zip_service: Arc) -> Self { self.core.zip_service = zip_service; self } -} \ No newline at end of file +} diff --git a/src/common/stubs.rs b/src/common/stubs.rs index fd4f064b..7a35ecac 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -168,6 +168,14 @@ impl FileWritePort for StubFileWritePort { Ok(File::default()) } + async fn copy_file( + &self, + _file_id: &str, + _target_folder_id: Option, + ) -> Result { + Ok(File::default()) + } + async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { Ok(File::default()) } @@ -483,6 +491,14 @@ impl FileManagementUseCase for StubFileManagementUseCase { Ok(FileDto::default()) } + async fn copy_file( + &self, + _file_id: &str, + _folder_id: Option, + ) -> Result { + Ok(FileDto::default()) + } + async fn rename_file(&self, _file_id: &str, _new_name: &str) -> Result { Ok(FileDto::default()) } diff --git a/src/infrastructure/repositories/mod.rs b/src/infrastructure/repositories/mod.rs index 9f75fa0a..94597b67 100644 --- a/src/infrastructure/repositories/mod.rs +++ b/src/infrastructure/repositories/mod.rs @@ -5,6 +5,6 @@ pub mod pg; // Re-exportar para facilitar acceso pub use pg::{ - FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, - SessionPgRepository, TrashDbRepository, UserPgRepository, + FileBlobReadRepository, FileBlobWriteRepository, FolderDbRepository, SessionPgRepository, + TrashDbRepository, UserPgRepository, }; diff --git a/src/infrastructure/repositories/pg/file_blob_read_repository.rs b/src/infrastructure/repositories/pg/file_blob_read_repository.rs index 51015fc3..7a3a3dc2 100644 --- a/src/infrastructure/repositories/pg/file_blob_read_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_read_repository.rs @@ -1,271 +1,265 @@ -//! PostgreSQL + Blob-backed file read repository. -//! -//! Implements `FileReadPort` using: -//! - `storage.files` table for metadata lookups -//! - `DedupPort` for reading content-addressable blobs from the filesystem - -use async_trait::async_trait; -use bytes::Bytes; -use futures::Stream; -use sqlx::PgPool; -use std::sync::Arc; - -use crate::application::ports::dedup_ports::DedupPort; -use crate::application::ports::storage_ports::FileReadPort; -use crate::common::errors::DomainError; -use crate::domain::entities::file::File; -use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::path_service::StoragePath; - -use super::folder_db_repository::FolderDbRepository; - -/// File read repository backed by PostgreSQL metadata + blob storage. -pub struct FileBlobReadRepository { - pool: Arc, - dedup: Arc, - folder_repo: Arc, -} - -impl FileBlobReadRepository { - pub fn new( - pool: Arc, - dedup: Arc, - folder_repo: Arc, - ) -> Self { - Self { - pool, - dedup, - folder_repo, - } - } - - /// Build a virtual StoragePath for a file. - async fn build_file_path( - &self, - folder_id: Option<&str>, - file_name: &str, - ) -> Result { - if let Some(fid) = folder_id { - let folder_path = self.folder_repo.get_folder_path(fid).await?; - Ok(folder_path.join(file_name)) - } else { - Ok(StoragePath::from_string(file_name)) - } - } - - /// Convert a database row into a `File` domain entity. - async fn row_to_file( - &self, - id: String, - name: String, - folder_id: Option, - size: i64, - mime_type: String, - created_at: i64, - modified_at: i64, - ) -> Result { - let storage_path = self - .build_file_path(folder_id.as_deref(), &name) - .await?; - File::with_timestamps( - id, - name, - storage_path, - size as u64, - mime_type, - folder_id, - created_at as u64, - modified_at as u64, - ) - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) - } - - /// Get the blob hash for a file. - async fn get_blob_hash(&self, file_id: &str) -> Result { - sqlx::query_scalar::<_, String>( - "SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed", - ) - .bind(file_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))? - .ok_or_else(|| DomainError::not_found("File", file_id)) - } -} - -#[async_trait] -impl FileReadPort for FileBlobReadRepository { - async fn get_file(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( - r#" - SELECT id::text, name, folder_id::text, size, mime_type, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.files - WHERE id = $1::uuid AND NOT is_trashed - "#, - ) - .bind(id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))? - .ok_or_else(|| DomainError::not_found("File", id))?; - - self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) - .await - } - - async fn list_files( - &self, - folder_id: Option<&str>, - ) -> Result, DomainError> { - let rows: Vec<(String, String, Option, i64, String, i64, i64)> = - if let Some(fid) = folder_id { - sqlx::query_as( - r#" - SELECT id::text, name, folder_id::text, size, mime_type, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.files - WHERE folder_id = $1::uuid AND NOT is_trashed - ORDER BY name - "#, - ) - .bind(fid) - .fetch_all(self.pool.as_ref()) - .await - } else { - sqlx::query_as( - r#" - SELECT id::text, name, folder_id::text, size, mime_type, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.files - WHERE folder_id IS NULL AND NOT is_trashed - ORDER BY name - "#, - ) - .fetch_all(self.pool.as_ref()) - .await - } - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; - - let mut files = Vec::with_capacity(rows.len()); - for (id, name, fid, size, mime, ca, ma) in rows { - files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?); - } - Ok(files) - } - - async fn get_file_content(&self, id: &str) -> Result, DomainError> { - let blob_hash = self.get_blob_hash(id).await?; - self.dedup.read_blob(&blob_hash).await - } - - async fn get_file_stream( - &self, - id: &str, - ) -> Result> + Send>, DomainError> { - // Read blob as bytes and wrap in a single-chunk stream. - // For very large files, a true streaming implementation from the - // blob file would be better, but DedupPort API currently returns bytes. - let blob_hash = self.get_blob_hash(id).await?; - let content = self.dedup.read_blob_bytes(&blob_hash).await?; - - let stream = futures::stream::once(async move { Ok(content) }); - Ok(Box::new(stream)) - } - - async fn get_file_range_stream( - &self, - id: &str, - start: u64, - end: Option, - ) -> Result> + Send>, DomainError> { - let blob_hash = self.get_blob_hash(id).await?; - let content = self.dedup.read_blob_bytes(&blob_hash).await?; - - let start = start as usize; - let end = end.map_or(content.len(), |e| e as usize).min(content.len()); - - if start >= content.len() { - return Ok(Box::new(futures::stream::empty())); - } - - let slice = content.slice(start..end); - let stream = futures::stream::once(async move { Ok(slice) }); - Ok(Box::new(stream)) - } - - async fn get_file_mmap(&self, id: &str) -> Result { - let blob_hash = self.get_blob_hash(id).await?; - self.dedup.read_blob_bytes(&blob_hash).await - } - - async fn get_file_path(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, (String, Option)>( - r#" - SELECT name, folder_id::text - FROM storage.files - WHERE id = $1::uuid AND NOT is_trashed - "#, - ) - .bind(id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))? - .ok_or_else(|| DomainError::not_found("File", id))?; - - self.build_file_path(row.1.as_deref(), &row.0).await - } - - async fn get_parent_folder_id(&self, path: &str) -> Result { - // Walk the path to find the parent folder, searching by folder names - let path = path.trim_start_matches('/').trim_end_matches('/'); - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); - - if segments.is_empty() { - return Err(DomainError::not_found("Folder", "empty path")); - } - - // For path "a/b/c/file.txt", the parent folder path is "a/b/c" - // But we don't know which part is folders vs filename. - // Walk segments trying to find matching folders. - let mut current_parent: Option = None; - - for segment in &segments { - let row = if let Some(ref pid) = current_parent { - sqlx::query_as::<_, (String,)>( - r#" - SELECT id::text FROM storage.folders - WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed - "#, - ) - .bind(segment) - .bind(pid) - .fetch_optional(self.pool.as_ref()) - .await - } else { - sqlx::query_as::<_, (String,)>( - r#" - SELECT id::text FROM storage.folders - WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed - "#, - ) - .bind(segment) - .fetch_optional(self.pool.as_ref()) - .await - } - .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?; - - match row { - Some(r) => current_parent = Some(r.0), - None => break, // This segment is not a folder → it's the filename - } - } - - current_parent.ok_or_else(|| { - DomainError::not_found("Folder", format!("parent for path: {path}")) - }) - } -} +//! PostgreSQL + Blob-backed file read repository. +//! +//! Implements `FileReadPort` using: +//! - `storage.files` table for metadata lookups +//! - `DedupPort` for reading content-addressable blobs from the filesystem + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::storage_ports::FileReadPort; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::path_service::StoragePath; + +use super::folder_db_repository::FolderDbRepository; + +/// File read repository backed by PostgreSQL metadata + blob storage. +pub struct FileBlobReadRepository { + pool: Arc, + dedup: Arc, + folder_repo: Arc, +} + +impl FileBlobReadRepository { + pub fn new( + pool: Arc, + dedup: Arc, + folder_repo: Arc, + ) -> Self { + Self { + pool, + dedup, + folder_repo, + } + } + + /// Build a virtual StoragePath for a file. + async fn build_file_path( + &self, + folder_id: Option<&str>, + file_name: &str, + ) -> Result { + if let Some(fid) = folder_id { + let folder_path = self.folder_repo.get_folder_path(fid).await?; + Ok(folder_path.join(file_name)) + } else { + Ok(StoragePath::from_string(file_name)) + } + } + + /// Convert a database row into a `File` domain entity. + async fn row_to_file( + &self, + id: String, + name: String, + folder_id: Option, + size: i64, + mime_type: String, + created_at: i64, + modified_at: i64, + ) -> Result { + let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?; + File::with_timestamps( + id, + name, + storage_path, + size as u64, + mime_type, + folder_id, + created_at as u64, + modified_at as u64, + ) + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("entity: {e}"))) + } + + /// Get the blob hash for a file. + async fn get_blob_hash(&self, file_id: &str) -> Result { + sqlx::query_scalar::<_, String>( + "SELECT blob_hash FROM storage.files WHERE id = $1::uuid AND NOT is_trashed", + ) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("hash lookup: {e}")))? + .ok_or_else(|| DomainError::not_found("File", file_id)) + } +} + +#[async_trait] +impl FileReadPort for FileBlobReadRepository { + async fn get_file(&self, id: &str) -> Result { + let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + r#" + SELECT id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.files + WHERE id = $1::uuid AND NOT is_trashed + "#, + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("get: {e}")))? + .ok_or_else(|| DomainError::not_found("File", id))?; + + self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) + .await + } + + async fn list_files(&self, folder_id: Option<&str>) -> Result, DomainError> { + let rows: Vec<(String, String, Option, i64, String, i64, i64)> = + if let Some(fid) = folder_id { + sqlx::query_as( + r#" + SELECT id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.files + WHERE folder_id = $1::uuid AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(fid) + .fetch_all(self.pool.as_ref()) + .await + } else { + sqlx::query_as( + r#" + SELECT id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.files + WHERE folder_id IS NULL AND NOT is_trashed + ORDER BY name + "#, + ) + .fetch_all(self.pool.as_ref()) + .await + } + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("list: {e}")))?; + + let mut files = Vec::with_capacity(rows.len()); + for (id, name, fid, size, mime, ca, ma) in rows { + files.push(self.row_to_file(id, name, fid, size, mime, ca, ma).await?); + } + Ok(files) + } + + async fn get_file_content(&self, id: &str) -> Result, DomainError> { + let blob_hash = self.get_blob_hash(id).await?; + self.dedup.read_blob(&blob_hash).await + } + + async fn get_file_stream( + &self, + id: &str, + ) -> Result> + Send>, DomainError> { + // Read blob as bytes and wrap in a single-chunk stream. + // For very large files, a true streaming implementation from the + // blob file would be better, but DedupPort API currently returns bytes. + let blob_hash = self.get_blob_hash(id).await?; + let content = self.dedup.read_blob_bytes(&blob_hash).await?; + + let stream = futures::stream::once(async move { Ok(content) }); + Ok(Box::new(stream)) + } + + async fn get_file_range_stream( + &self, + id: &str, + start: u64, + end: Option, + ) -> Result> + Send>, DomainError> { + let blob_hash = self.get_blob_hash(id).await?; + let content = self.dedup.read_blob_bytes(&blob_hash).await?; + + let start = start as usize; + let end = end.map_or(content.len(), |e| e as usize).min(content.len()); + + if start >= content.len() { + return Ok(Box::new(futures::stream::empty())); + } + + let slice = content.slice(start..end); + let stream = futures::stream::once(async move { Ok(slice) }); + Ok(Box::new(stream)) + } + + async fn get_file_mmap(&self, id: &str) -> Result { + let blob_hash = self.get_blob_hash(id).await?; + self.dedup.read_blob_bytes(&blob_hash).await + } + + async fn get_file_path(&self, id: &str) -> Result { + let row = sqlx::query_as::<_, (String, Option)>( + r#" + SELECT name, folder_id::text + FROM storage.files + WHERE id = $1::uuid AND NOT is_trashed + "#, + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path: {e}")))? + .ok_or_else(|| DomainError::not_found("File", id))?; + + self.build_file_path(row.1.as_deref(), &row.0).await + } + + async fn get_parent_folder_id(&self, path: &str) -> Result { + // Walk the path to find the parent folder, searching by folder names + let path = path.trim_start_matches('/').trim_end_matches('/'); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return Err(DomainError::not_found("Folder", "empty path")); + } + + // For path "a/b/c/file.txt", the parent folder path is "a/b/c" + // But we don't know which part is folders vs filename. + // Walk segments trying to find matching folders. + let mut current_parent: Option = None; + + for segment in &segments { + let row = if let Some(ref pid) = current_parent { + sqlx::query_as::<_, (String,)>( + r#" + SELECT id::text FROM storage.folders + WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed + "#, + ) + .bind(segment) + .bind(pid) + .fetch_optional(self.pool.as_ref()) + .await + } else { + sqlx::query_as::<_, (String,)>( + r#" + SELECT id::text FROM storage.folders + WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed + "#, + ) + .bind(segment) + .fetch_optional(self.pool.as_ref()) + .await + } + .map_err(|e| DomainError::internal_error("FileBlobRead", format!("path walk: {e}")))?; + + match row { + Some(r) => current_parent = Some(r.0), + None => break, // This segment is not a folder → it's the filename + } + } + + current_parent + .ok_or_else(|| DomainError::not_found("Folder", format!("parent for path: {path}"))) + } +} diff --git a/src/infrastructure/repositories/pg/file_blob_write_repository.rs b/src/infrastructure/repositories/pg/file_blob_write_repository.rs index c545c97a..8c38d2e8 100644 --- a/src/infrastructure/repositories/pg/file_blob_write_repository.rs +++ b/src/infrastructure/repositories/pg/file_blob_write_repository.rs @@ -1,435 +1,526 @@ -//! PostgreSQL + Blob-backed file write repository. -//! -//! Implements `FileWritePort` using: -//! - `storage.files` table for metadata -//! - `DedupPort` for content-addressable blob storage on the filesystem - -use async_trait::async_trait; -use bytes::Bytes; -use futures::Stream; -use sqlx::PgPool; -use std::path::PathBuf; -use std::pin::Pin; -use std::sync::Arc; - -use crate::application::ports::dedup_ports::DedupPort; -use crate::application::ports::storage_ports::FileWritePort; -use crate::common::errors::DomainError; -use crate::domain::entities::file::File; -use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::path_service::StoragePath; - -use super::folder_db_repository::FolderDbRepository; - -/// File write repository backed by PostgreSQL metadata + blob storage. -pub struct FileBlobWriteRepository { - pool: Arc, - dedup: Arc, - folder_repo: Arc, -} - -impl FileBlobWriteRepository { - pub fn new( - pool: Arc, - dedup: Arc, - folder_repo: Arc, - ) -> Self { - Self { - pool, - dedup, - folder_repo, - } - } - - /// Build a virtual StoragePath for a file from its DB metadata. - async fn build_file_path( - &self, - folder_id: Option<&str>, - file_name: &str, - ) -> Result { - if let Some(fid) = folder_id { - let folder_path = self.folder_repo.get_folder_path(fid).await?; - Ok(folder_path.join(file_name)) - } else { - Ok(StoragePath::from_string(file_name)) - } - } - - /// Convert a database row into a `File` domain entity. - async fn row_to_file( - &self, - id: String, - name: String, - folder_id: Option, - size: i64, - mime_type: String, - created_at: i64, - modified_at: i64, - ) -> Result { - let storage_path = self - .build_file_path(folder_id.as_deref(), &name) - .await?; - File::with_timestamps( - id, - name, - storage_path, - size as u64, - mime_type, - folder_id, - created_at as u64, - modified_at as u64, - ) - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}"))) - } - - /// Derive user_id from the parent folder, or error if folder_id is None. - async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result { - match folder_id { - Some(fid) => self.folder_repo.get_folder_user_id(fid).await, - None => Err(DomainError::internal_error( - "FileBlobWrite", - "folder_id is required to determine file owner", - )), - } - } -} - -#[async_trait] -impl FileWritePort for FileBlobWriteRepository { - async fn save_file( - &self, - name: String, - folder_id: Option, - content_type: String, - content: Vec, - ) -> Result { - let user_id = self.resolve_user_id(folder_id.as_deref()).await?; - let size = content.len() as i64; - - // Store content in blob store - let dedup_result = self - .dedup - .store_bytes(&content, Some(content_type.clone())) - .await?; - let blob_hash = dedup_result.hash().to_string(); - - // Insert file metadata - let row = sqlx::query_as::<_, (String, i64, i64)>( - r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) - RETURNING id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(&name) - .bind(&folder_id) - .bind(&user_id) - .bind(&blob_hash) - .bind(size) - .bind(&content_type) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| { - if let sqlx::Error::Database(ref db_err) = e { - if db_err.code().as_deref() == Some("23505") { - return DomainError::already_exists( - "File", - format!("{name} already exists in folder"), - ); - } - } - DomainError::internal_error("FileBlobWrite", format!("insert: {e}")) - })?; - - tracing::info!( - "💾 BLOB WRITE: {} ({} bytes, hash: {})", - name, - size, - &blob_hash[..12] - ); - - self.row_to_file( - row.0, - name, - folder_id, - size, - content_type, - row.1, - row.2, - ) - .await - } - - async fn save_file_from_stream( - &self, - name: String, - folder_id: Option, - content_type: String, - stream: Pin> + Send>>, - ) -> Result { - use futures::StreamExt; - - // Collect stream into bytes (blobs are content-addressed, need full content for hash) - let mut content = Vec::new(); - let mut stream = stream; - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|e| { - DomainError::internal_error("FileBlobWrite", format!("stream read: {e}")) - })?; - content.extend_from_slice(&chunk); - } - - self.save_file(name, folder_id, content_type, content).await - } - - async fn move_file( - &self, - file_id: &str, - target_folder_id: Option, - ) -> Result { - // If moving to a different folder, get the new user_id (must be same user) - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( - r#" - UPDATE storage.files - SET folder_id = $1::uuid, updated_at = NOW() - WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, folder_id::text, size, mime_type, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(&target_folder_id) - .bind(file_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))? - .ok_or_else(|| DomainError::not_found("File", file_id))?; - - self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) - .await - } - - async fn rename_file( - &self, - file_id: &str, - new_name: &str, - ) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( - r#" - UPDATE storage.files - SET name = $1, updated_at = NOW() - WHERE id = $2::uuid AND NOT is_trashed - RETURNING id::text, name, folder_id::text, size, mime_type, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(new_name) - .bind(file_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| { - if let sqlx::Error::Database(ref db_err) = e { - if db_err.code().as_deref() == Some("23505") { - return DomainError::already_exists( - "File", - format!("{new_name} already exists"), - ); - } - } - DomainError::internal_error("FileBlobWrite", format!("rename: {e}")) - })? - .ok_or_else(|| DomainError::not_found("File", file_id))?; - - self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) - .await - } - - async fn delete_file(&self, id: &str) -> Result<(), DomainError> { - // Get blob_hash before deleting so we can decrement ref - let hash = sqlx::query_scalar::<_, String>( - "SELECT blob_hash FROM storage.files WHERE id = $1::uuid", - ) - .bind(id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("hash lookup: {e}")))?; - - let result = sqlx::query("DELETE FROM storage.files WHERE id = $1::uuid") - .bind(id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("File", id)); - } - - // Decrement blob reference - if let Some(h) = hash { - if let Err(e) = self.dedup.remove_reference(&h).await { - tracing::warn!("Failed to decrement blob ref for {}: {}", &h[..12], e); - } - } - - Ok(()) - } - - async fn update_file_content( - &self, - file_id: &str, - content: Vec, - ) -> Result<(), DomainError> { - // Get old blob hash to decrement ref - let old_hash = sqlx::query_scalar::<_, String>( - "SELECT blob_hash FROM storage.files WHERE id = $1::uuid", - ) - .bind(file_id) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("old hash: {e}")))? - .ok_or_else(|| DomainError::not_found("File", file_id))?; - - // Store new content - let new_size = content.len() as i64; - let dedup_result = self.dedup.store_bytes(&content, None).await?; - let new_hash = dedup_result.hash().to_string(); - - // Update file metadata - sqlx::query( - r#" - UPDATE storage.files - SET blob_hash = $1, size = $2, updated_at = NOW() - WHERE id = $3::uuid - "#, - ) - .bind(&new_hash) - .bind(new_size) - .bind(file_id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("update: {e}")))?; - - // Decrement old blob ref (only if hash changed) - if old_hash != new_hash { - if let Err(e) = self.dedup.remove_reference(&old_hash).await { - tracing::warn!( - "Failed to decrement old blob ref {}: {}", - &old_hash[..12], - e - ); - } - } - - Ok(()) - } - - async fn register_file_deferred( - &self, - name: String, - folder_id: Option, - content_type: String, - size: u64, - ) -> Result<(File, PathBuf), DomainError> { - let user_id = self.resolve_user_id(folder_id.as_deref()).await?; - - // For deferred registration we use a placeholder hash. - // The write-behind cache will call update_file_content later. - let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000"; - - let row = sqlx::query_as::<_, (String, i64, i64)>( - r#" - INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) - VALUES ($1, $2::uuid, $3, $4, $5, $6) - RETURNING id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(&name) - .bind(&folder_id) - .bind(&user_id) - .bind(placeholder_hash) - .bind(size as i64) - .bind(&content_type) - .fetch_one(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?; - - let file = self - .row_to_file( - row.0.clone(), - name, - folder_id, - size as i64, - content_type, - row.1, - row.2, - ) - .await?; - - // The target_path is not meaningful for blob storage (content goes to .blobs/) - // but the WriteBehindCache API requires it. We return a synthetic path. - let target_path = PathBuf::from(format!(".pending/{}", row.0)); - - Ok((file, target_path)) - } - - // ── Trash operations ── - - async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { - let result = sqlx::query( - r#" - UPDATE storage.files - SET is_trashed = TRUE, - trashed_at = NOW(), - original_folder_id = folder_id, - updated_at = NOW() - WHERE id = $1::uuid AND NOT is_trashed - "#, - ) - .bind(file_id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("File", file_id)); - } - Ok(()) - } - - async fn restore_from_trash( - &self, - file_id: &str, - _original_path: &str, - ) -> Result<(), DomainError> { - let result = sqlx::query( - r#" - UPDATE storage.files - SET is_trashed = FALSE, - trashed_at = NULL, - folder_id = COALESCE(original_folder_id, folder_id), - original_folder_id = NULL, - updated_at = NOW() - WHERE id = $1::uuid AND is_trashed - "#, - ) - .bind(file_id) - .execute(self.pool.as_ref()) - .await - .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("File", file_id)); - } - Ok(()) - } - - async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { - // Same as delete_file — removes from DB and decrements blob ref - self.delete_file(file_id).await - } -} +//! PostgreSQL + Blob-backed file write repository. +//! +//! Implements `FileWritePort` using: +//! - `storage.files` table for metadata +//! - `DedupPort` for content-addressable blob storage on the filesystem + +use async_trait::async_trait; +use bytes::Bytes; +use futures::Stream; +use sqlx::PgPool; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use crate::application::ports::dedup_ports::DedupPort; +use crate::application::ports::storage_ports::FileWritePort; +use crate::common::errors::DomainError; +use crate::domain::entities::file::File; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::path_service::StoragePath; + +use super::folder_db_repository::FolderDbRepository; + +/// File write repository backed by PostgreSQL metadata + blob storage. +pub struct FileBlobWriteRepository { + pool: Arc, + dedup: Arc, + folder_repo: Arc, +} + +impl FileBlobWriteRepository { + pub fn new( + pool: Arc, + dedup: Arc, + folder_repo: Arc, + ) -> Self { + Self { + pool, + dedup, + folder_repo, + } + } + + /// Build a virtual StoragePath for a file from its DB metadata. + async fn build_file_path( + &self, + folder_id: Option<&str>, + file_name: &str, + ) -> Result { + if let Some(fid) = folder_id { + let folder_path = self.folder_repo.get_folder_path(fid).await?; + Ok(folder_path.join(file_name)) + } else { + Ok(StoragePath::from_string(file_name)) + } + } + + /// Convert a database row into a `File` domain entity. + async fn row_to_file( + &self, + id: String, + name: String, + folder_id: Option, + size: i64, + mime_type: String, + created_at: i64, + modified_at: i64, + ) -> Result { + let storage_path = self.build_file_path(folder_id.as_deref(), &name).await?; + File::with_timestamps( + id, + name, + storage_path, + size as u64, + mime_type, + folder_id, + created_at as u64, + modified_at as u64, + ) + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("entity: {e}"))) + } + + /// Derive user_id from the parent folder, or error if folder_id is None. + async fn resolve_user_id(&self, folder_id: Option<&str>) -> Result { + match folder_id { + Some(fid) => self.folder_repo.get_folder_user_id(fid).await, + None => Err(DomainError::internal_error( + "FileBlobWrite", + "folder_id is required to determine file owner", + )), + } + } +} + +#[async_trait] +impl FileWritePort for FileBlobWriteRepository { + async fn save_file( + &self, + name: String, + folder_id: Option, + content_type: String, + content: Vec, + ) -> Result { + let user_id = self.resolve_user_id(folder_id.as_deref()).await?; + let size = content.len() as i64; + + // Store content in blob store + let dedup_result = self + .dedup + .store_bytes(&content, Some(content_type.clone())) + .await?; + let blob_hash = dedup_result.hash().to_string(); + + // Insert file metadata — if this fails, compensate by removing the blob ref + let row = match sqlx::query_as::<_, (String, i64, i64)>( + r#" + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + VALUES ($1, $2::uuid, $3, $4, $5, $6) + RETURNING id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(&name) + .bind(&folder_id) + .bind(&user_id) + .bind(&blob_hash) + .bind(size) + .bind(&content_type) + .fetch_one(self.pool.as_ref()) + .await + { + Ok(row) => row, + Err(e) => { + // ── Compensation: undo the blob ref so it doesn't become orphaned ── + if let Err(rollback_err) = self.dedup.remove_reference(&blob_hash).await { + tracing::error!( + "Blob orphaned after failed INSERT — hash: {}, err: {}", + &blob_hash[..12], + rollback_err + ); + } + if let sqlx::Error::Database(ref db_err) = e { + if db_err.code().as_deref() == Some("23505") { + return Err(DomainError::already_exists( + "File", + format!("{name} already exists in folder"), + )); + } + } + return Err(DomainError::internal_error( + "FileBlobWrite", + format!("insert: {e}"), + )); + } + }; + + tracing::info!( + "💾 BLOB WRITE: {} ({} bytes, hash: {})", + name, + size, + &blob_hash[..12] + ); + + self.row_to_file(row.0, name, folder_id, size, content_type, row.1, row.2) + .await + } + + async fn save_file_from_stream( + &self, + name: String, + folder_id: Option, + content_type: String, + stream: Pin> + Send>>, + ) -> Result { + use futures::StreamExt; + + // Collect stream into bytes (blobs are content-addressed, need full content for hash) + let mut content = Vec::new(); + let mut stream = stream; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| { + DomainError::internal_error("FileBlobWrite", format!("stream read: {e}")) + })?; + content.extend_from_slice(&chunk); + } + + self.save_file(name, folder_id, content_type, content).await + } + + async fn move_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result { + // If moving to a different folder, get the new user_id (must be same user) + let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + r#" + UPDATE storage.files + SET folder_id = $1::uuid, updated_at = NOW() + WHERE id = $2::uuid AND NOT is_trashed + RETURNING id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(&target_folder_id) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("move: {e}")))? + .ok_or_else(|| DomainError::not_found("File", file_id))?; + + self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) + .await + } + + async fn copy_file( + &self, + file_id: &str, + target_folder_id: Option, + ) -> Result { + // Atomic CTE: read source file → insert new row with same blob_hash → increment ref_count. + // Single round-trip; blob content is NOT copied (dedup makes this zero-copy). + let target_fid = target_folder_id.clone(); + + let row = sqlx::query_as::< + _, + ( + String, + String, + Option, + i64, + String, + i64, + i64, + String, + ), + >( + r#" + WITH src AS ( + SELECT name, folder_id, user_id, blob_hash, size, mime_type + FROM storage.files + WHERE id = $1::uuid AND NOT is_trashed + ), + new_file AS ( + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + SELECT name, + COALESCE($2::uuid, folder_id), + user_id, + blob_hash, + size, + mime_type + FROM src + RETURNING id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint, + blob_hash + ) + SELECT * FROM new_file + "#, + ) + .bind(file_id) + .bind(&target_fid) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.code().as_deref() == Some("23505") { + return DomainError::already_exists( + "File", + "File with that name already exists in target folder".to_string(), + ); + } + } + DomainError::internal_error("FileBlobWrite", format!("copy: {e}")) + })? + .ok_or_else(|| DomainError::not_found("File", file_id))?; + + let blob_hash = &row.7; + + // Increment blob reference count (best-effort; INSERT already succeeded) + if let Err(e) = self.dedup.add_reference(blob_hash).await { + tracing::warn!( + "Failed to increment blob ref for copy {}: {}", + &blob_hash[..12], + e + ); + } + + tracing::info!( + "📋 BLOB COPY: {} (hash: {}, zero-copy via dedup)", + row.1, + &blob_hash[..12] + ); + + self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) + .await + } + + async fn rename_file(&self, file_id: &str, new_name: &str) -> Result { + let row = sqlx::query_as::<_, (String, String, Option, i64, String, i64, i64)>( + r#" + UPDATE storage.files + SET name = $1, updated_at = NOW() + WHERE id = $2::uuid AND NOT is_trashed + RETURNING id::text, name, folder_id::text, size, mime_type, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(new_name) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.code().as_deref() == Some("23505") { + return DomainError::already_exists( + "File", + format!("{new_name} already exists"), + ); + } + } + DomainError::internal_error("FileBlobWrite", format!("rename: {e}")) + })? + .ok_or_else(|| DomainError::not_found("File", file_id))?; + + self.row_to_file(row.0, row.1, row.2, row.3, row.4, row.5, row.6) + .await + } + + async fn delete_file(&self, id: &str) -> Result<(), DomainError> { + // Atomic DELETE RETURNING — one round-trip instead of SELECT + DELETE + let hash = sqlx::query_scalar::<_, String>( + "DELETE FROM storage.files WHERE id = $1::uuid RETURNING blob_hash", + ) + .bind(id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("delete: {e}")))? + .ok_or_else(|| DomainError::not_found("File", id))?; + + // Decrement blob reference (best-effort after successful DELETE) + if let Err(e) = self.dedup.remove_reference(&hash).await { + tracing::warn!("Failed to decrement blob ref for {}: {}", &hash[..12], e); + } + + Ok(()) + } + + async fn update_file_content( + &self, + file_id: &str, + content: Vec, + ) -> Result<(), DomainError> { + // Store new content first (blob store is idempotent) + let new_size = content.len() as i64; + let dedup_result = self.dedup.store_bytes(&content, None).await?; + let new_hash = dedup_result.hash().to_string(); + + // Atomic CTE: capture old hash then update in one round-trip, no TOCTOU. + // The `old` CTE locks + reads the row *before* the update touches it. + let old_hash = match sqlx::query_scalar::<_, String>( + r#" + WITH old AS ( + SELECT id, blob_hash FROM storage.files WHERE id = $3::uuid FOR UPDATE + ) + UPDATE storage.files f + SET blob_hash = $1, size = $2, updated_at = NOW() + FROM old + WHERE f.id = old.id + RETURNING old.blob_hash + "#, + ) + .bind(&new_hash) + .bind(new_size) + .bind(file_id) + .fetch_optional(self.pool.as_ref()) + .await + { + Ok(Some(old)) => old, + Ok(None) => { + // File not found — compensate: remove the new blob ref + if let Err(e) = self.dedup.remove_reference(&new_hash).await { + tracing::error!("Blob orphaned after missing file: {}", e); + } + return Err(DomainError::not_found("File", file_id)); + } + Err(e) => { + // UPDATE failed — compensate: remove the new blob ref + if let Err(rollback_err) = self.dedup.remove_reference(&new_hash).await { + tracing::error!( + "Blob orphaned after failed UPDATE — hash: {}, err: {}", + &new_hash[..12], + rollback_err + ); + } + return Err(DomainError::internal_error( + "FileBlobWrite", + format!("update: {e}"), + )); + } + }; + + // Decrement old blob ref (only if hash changed, best-effort) + if old_hash != new_hash { + if let Err(e) = self.dedup.remove_reference(&old_hash).await { + tracing::warn!( + "Failed to decrement old blob ref {}: {}", + &old_hash[..12], + e + ); + } + } + + Ok(()) + } + + async fn register_file_deferred( + &self, + name: String, + folder_id: Option, + content_type: String, + size: u64, + ) -> Result<(File, PathBuf), DomainError> { + let user_id = self.resolve_user_id(folder_id.as_deref()).await?; + + // For deferred registration we use a placeholder hash. + // The write-behind cache will call update_file_content later. + let placeholder_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + + let row = sqlx::query_as::<_, (String, i64, i64)>( + r#" + INSERT INTO storage.files (name, folder_id, user_id, blob_hash, size, mime_type) + VALUES ($1, $2::uuid, $3, $4, $5, $6) + RETURNING id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(&name) + .bind(&folder_id) + .bind(&user_id) + .bind(placeholder_hash) + .bind(size as i64) + .bind(&content_type) + .fetch_one(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("deferred: {e}")))?; + + let file = self + .row_to_file( + row.0.clone(), + name, + folder_id, + size as i64, + content_type, + row.1, + row.2, + ) + .await?; + + // The target_path is not meaningful for blob storage (content goes to .blobs/) + // but the WriteBehindCache API requires it. We return a synthetic path. + let target_path = PathBuf::from(format!(".pending/{}", row.0)); + + Ok((file, target_path)) + } + + // ── Trash operations ── + + async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError> { + let result = sqlx::query( + r#" + UPDATE storage.files + SET is_trashed = TRUE, + trashed_at = NOW(), + original_folder_id = folder_id, + updated_at = NOW() + WHERE id = $1::uuid AND NOT is_trashed + "#, + ) + .bind(file_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("trash: {e}")))?; + + if result.rows_affected() == 0 { + return Err(DomainError::not_found("File", file_id)); + } + Ok(()) + } + + async fn restore_from_trash( + &self, + file_id: &str, + _original_path: &str, + ) -> Result<(), DomainError> { + let result = sqlx::query( + r#" + UPDATE storage.files + SET is_trashed = FALSE, + trashed_at = NULL, + folder_id = COALESCE(original_folder_id, folder_id), + original_folder_id = NULL, + updated_at = NOW() + WHERE id = $1::uuid AND is_trashed + "#, + ) + .bind(file_id) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("FileBlobWrite", format!("restore: {e}")))?; + + if result.rows_affected() == 0 { + return Err(DomainError::not_found("File", file_id)); + } + Ok(()) + } + + async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError> { + // Same as delete_file — removes from DB and decrements blob ref + self.delete_file(file_id).await + } +} diff --git a/src/infrastructure/repositories/pg/folder_db_repository.rs b/src/infrastructure/repositories/pg/folder_db_repository.rs index cf285710..c1223267 100644 --- a/src/infrastructure/repositories/pg/folder_db_repository.rs +++ b/src/infrastructure/repositories/pg/folder_db_repository.rs @@ -1,603 +1,584 @@ -//! PostgreSQL-backed folder repository. -//! -//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the -//! `storage.folders` table. Folders are purely virtual — no physical -//! directories are created on the filesystem. - -use async_trait::async_trait; -use sqlx::PgPool; -use std::sync::Arc; - -use crate::common::errors::DomainError; -use crate::domain::entities::folder::Folder; -use crate::domain::repositories::folder_repository::FolderRepository; -use crate::domain::services::path_service::StoragePath; - -/// PostgreSQL-backed folder repository. -/// -/// All folder metadata lives in the `storage.folders` table. The physical -/// filesystem is never touched for folder operations. -pub struct FolderDbRepository { - pool: Option>, -} - -impl FolderDbRepository { - pub fn new(pool: Arc) -> Self { - Self { pool: Some(pool) } - } - - /// Creates a stub instance for `AppState::default()`. - /// This is never called in production — only used for route scaffolding. - pub fn new_stub() -> Self { - Self { pool: None } - } - - /// Get the pool, panicking if stub. - fn pool(&self) -> &PgPool { - self.pool.as_deref().expect("FolderDbRepository: pool not available (stub instance)") - } - - // ── helpers ────────────────────────────────────────────────── - - /// Build the full virtual path for a folder by walking up the `parent_id` chain. - async fn build_folder_path(&self, folder_id: &str) -> Result { - // CTE-based recursive query to build path segments - let _rows = sqlx::query_as::<_, (String,)>( - r#" - WITH RECURSIVE ancestors AS ( - SELECT id, name, parent_id - FROM storage.folders - WHERE id = $1::uuid - UNION ALL - SELECT f.id, f.name, f.parent_id - FROM storage.folders f - JOIN ancestors a ON f.id = a.parent_id - ) - SELECT name FROM ancestors ORDER BY name - "#, - ) - .bind(folder_id) - .fetch_all(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?; - - // Actually we need a proper ordering. Let me rewrite with depth tracking. - // Re-query with depth. - let rows = sqlx::query_as::<_, (String, i32)>( - r#" - WITH RECURSIVE ancestors AS ( - SELECT id, name, parent_id, 0 AS depth - FROM storage.folders - WHERE id = $1::uuid - UNION ALL - SELECT f.id, f.name, f.parent_id, a.depth + 1 - FROM storage.folders f - JOIN ancestors a ON f.id = a.parent_id - ) - SELECT name, depth FROM ancestors ORDER BY depth DESC - "#, - ) - .bind(folder_id) - .fetch_all(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?; - - let path_parts: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect(); - let path_str = path_parts.join("/"); - Ok(StoragePath::from_string(&path_str)) - } - - /// Convert a database row into a `Folder` domain entity. - async fn row_to_folder( - &self, - id: String, - name: String, - parent_id: Option, - created_at: i64, - modified_at: i64, - ) -> Result { - let storage_path = self.build_folder_path(&id).await?; - Folder::with_timestamps( - id, - name, - storage_path, - parent_id, - created_at as u64, - modified_at as u64, - ) - .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) - } -} - -#[async_trait] -impl FolderRepository for FolderDbRepository { - async fn create_folder( - &self, - name: String, - parent_id: Option, - ) -> Result { - // Derive user_id from parent folder. Root-level folders require the - // caller to have set up the home folder beforehand (done during user - // registration). - let user_id: String = if let Some(ref pid) = parent_id { - sqlx::query_scalar::<_, String>( - "SELECT user_id FROM storage.folders WHERE id = $1::uuid", - ) - .bind(pid) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))? - .ok_or_else(|| DomainError::not_found("Folder", pid))? - } else { - return Err(DomainError::internal_error( - "FolderDb", - "Cannot create root folder without user_id — use create_home_folder instead", - )); - }; - - let row = sqlx::query_as::<_, (String, i64, i64)>( - r#" - INSERT INTO storage.folders (name, parent_id, user_id) - VALUES ($1, $2::uuid, $3) - RETURNING id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(&name) - .bind(&parent_id) - .bind(&user_id) - .fetch_one(self.pool()) - .await - .map_err(|e| { - if let sqlx::Error::Database(ref db_err) = e { - if db_err.code().as_deref() == Some("23505") { - return DomainError::already_exists( - "Folder", - format!("{name} already exists in parent"), - ); - } - } - DomainError::internal_error("FolderDb", format!("insert: {e}")) - })?; - - self.row_to_folder(row.0, name, parent_id, row.1, row.2) - .await - } - - async fn get_folder(&self, id: &str) -> Result { - let row = sqlx::query_as::<_, (String, String, Option, i64, i64)>( - r#" - SELECT id::text, name, parent_id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE id = $1::uuid AND NOT is_trashed - "#, - ) - .bind(id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? - .ok_or_else(|| DomainError::not_found("Folder", id))?; - - self.row_to_folder(row.0, row.1, row.2, row.3, row.4) - .await - } - - async fn get_folder_by_path( - &self, - storage_path: &StoragePath, - ) -> Result { - // Walk the path segments to find the folder. - let path_str = storage_path.to_string(); - let segments: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect(); - - if segments.is_empty() { - return Err(DomainError::not_found("Folder", "empty path")); - } - - let mut current_parent: Option = None; - let mut current_id = String::new(); - - for segment in &segments { - let row = if let Some(ref pid) = current_parent { - sqlx::query_as::<_, (String,)>( - r#" - SELECT id::text FROM storage.folders - WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed - "#, - ) - .bind(segment) - .bind(pid) - .fetch_optional(self.pool()) - .await - } else { - sqlx::query_as::<_, (String,)>( - r#" - SELECT id::text FROM storage.folders - WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed - "#, - ) - .bind(segment) - .fetch_optional(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("path walk: {e}")))? - .ok_or_else(|| { - DomainError::not_found("Folder", format!("segment '{segment}' in path")) - })?; - - current_id = row.0; - current_parent = Some(current_id.clone()); - } - - self.get_folder(¤t_id).await - } - - async fn list_folders( - &self, - parent_id: Option<&str>, - ) -> Result, DomainError> { - let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" - SELECT id::text, name, parent_id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE parent_id = $1::uuid AND NOT is_trashed - ORDER BY name - "#, - ) - .bind(pid) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" - SELECT id::text, name, parent_id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE parent_id IS NULL AND NOT is_trashed - ORDER BY name - "#, - ) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; - - let mut folders = Vec::with_capacity(rows.len()); - for (id, name, pid, ca, ma) in rows { - folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); - } - Ok(folders) - } - - async fn list_folders_paginated( - &self, - parent_id: Option<&str>, - offset: usize, - limit: usize, - include_total: bool, - ) -> Result<(Vec, Option), DomainError> { - let total = if include_total { - let count: i64 = if let Some(pid) = parent_id { - sqlx::query_scalar( - "SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed", - ) - .bind(pid) - .fetch_one(self.pool()) - .await - } else { - sqlx::query_scalar( - "SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed", - ) - .fetch_one(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("count: {e}")))?; - Some(count as usize) - } else { - None - }; - - let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { - sqlx::query_as( - r#" - SELECT id::text, name, parent_id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE parent_id = $1::uuid AND NOT is_trashed - ORDER BY name - LIMIT $2 OFFSET $3 - "#, - ) - .bind(pid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } else { - sqlx::query_as( - r#" - SELECT id::text, name, parent_id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE parent_id IS NULL AND NOT is_trashed - ORDER BY name - LIMIT $1 OFFSET $2 - "#, - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(self.pool()) - .await - } - .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; - - let mut folders = Vec::with_capacity(rows.len()); - for (id, name, pid, ca, ma) in rows { - folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); - } - Ok((folders, total)) - } - - async fn rename_folder( - &self, - id: &str, - new_name: String, - ) -> Result { - sqlx::query( - r#" - UPDATE storage.folders - SET name = $1, updated_at = NOW() - WHERE id = $2::uuid AND NOT is_trashed - "#, - ) - .bind(&new_name) - .bind(id) - .execute(self.pool()) - .await - .map_err(|e| { - if let sqlx::Error::Database(ref db_err) = e { - if db_err.code().as_deref() == Some("23505") { - return DomainError::already_exists( - "Folder", - format!("{new_name} already exists"), - ); - } - } - DomainError::internal_error("FolderDb", format!("rename: {e}")) - })?; - - self.get_folder(id).await - } - - async fn move_folder( - &self, - id: &str, - new_parent_id: Option<&str>, - ) -> Result { - sqlx::query( - r#" - UPDATE storage.folders - SET parent_id = $1::uuid, updated_at = NOW() - WHERE id = $2::uuid AND NOT is_trashed - "#, - ) - .bind(new_parent_id) - .bind(id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?; - - self.get_folder(id).await - } - - async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { - // Hard delete folder and all descendants (CASCADE handles children) - let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") - .bind(id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("delete: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("Folder", id)); - } - Ok(()) - } - - async fn folder_exists(&self, storage_path: &StoragePath) -> Result { - // Try to find by walking the path - match self.get_folder_by_path(storage_path).await { - Ok(_) => Ok(true), - Err(e) if e.to_string().contains("not found") => Ok(false), - Err(e) => Err(e), - } - } - - async fn get_folder_path(&self, id: &str) -> Result { - self.build_folder_path(id).await - } - - // ── Trash operations ── - - async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { - // Soft-delete: set is_trashed = true and remember original parent - let result = sqlx::query( - r#" - UPDATE storage.folders - SET is_trashed = TRUE, - trashed_at = NOW(), - original_parent_id = parent_id, - updated_at = NOW() - WHERE id = $1::uuid AND NOT is_trashed - "#, - ) - .bind(folder_id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("Folder", folder_id)); - } - - // Also trash all files inside the folder (recursively) - sqlx::query( - r#" - WITH RECURSIVE descendants AS ( - SELECT id FROM storage.folders WHERE id = $1::uuid - UNION ALL - SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id - ) - UPDATE storage.files - SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id - WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed - "#, - ) - .bind(folder_id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("trash files: {e}")))?; - - Ok(()) - } - - async fn restore_from_trash( - &self, - folder_id: &str, - _original_path: &str, - ) -> Result<(), DomainError> { - // Restore: set is_trashed = false, restore parent_id from original_parent_id - let result = sqlx::query( - r#" - UPDATE storage.folders - SET is_trashed = FALSE, - trashed_at = NULL, - parent_id = COALESCE(original_parent_id, parent_id), - original_parent_id = NULL, - updated_at = NOW() - WHERE id = $1::uuid AND is_trashed - "#, - ) - .bind(folder_id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("Folder", folder_id)); - } - - // Also restore files that were trashed with this folder - sqlx::query( - r#" - WITH RECURSIVE descendants AS ( - SELECT id FROM storage.folders WHERE id = $1::uuid - UNION ALL - SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id - ) - UPDATE storage.files - SET is_trashed = FALSE, - trashed_at = NULL, - folder_id = COALESCE(original_folder_id, folder_id), - original_folder_id = NULL - WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed - "#, - ) - .bind(folder_id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("restore files: {e}")))?; - - Ok(()) - } - - async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> { - // Permanently delete — CASCADE handles children - let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") - .bind(folder_id) - .execute(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete: {e}")))?; - - if result.rows_affected() == 0 { - return Err(DomainError::not_found("Folder", folder_id)); - } - Ok(()) - } -} - -// ── Extra helpers for blob-storage bootstrap ── - -impl FolderDbRepository { - /// Creates a root-level home folder for a user. - /// This is called during user registration. - pub async fn create_home_folder( - &self, - user_id: &str, - name: &str, - ) -> Result { - let row = sqlx::query_as::<_, (String, i64, i64)>( - r#" - INSERT INTO storage.folders (name, parent_id, user_id) - VALUES ($1, NULL, $2) - ON CONFLICT DO NOTHING - RETURNING id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - "#, - ) - .bind(name) - .bind(user_id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; - - match row { - Some((id, ca, ma)) => { - self.row_to_folder(id, name.to_string(), None, ca, ma).await - } - None => { - // Already exists — fetch it - let existing = sqlx::query_as::<_, (String, i64, i64)>( - r#" - SELECT id::text, - EXTRACT(EPOCH FROM created_at)::bigint, - EXTRACT(EPOCH FROM updated_at)::bigint - FROM storage.folders - WHERE name = $1 AND user_id = $2 AND parent_id IS NULL - "#, - ) - .bind(name) - .bind(user_id) - .fetch_one(self.pool()) - .await - .map_err(|e| { - DomainError::internal_error("FolderDb", format!("home fetch: {e}")) - })?; - self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2) - .await - } - } - } - - /// Returns user_id for a given folder. Used by file repositories. - pub async fn get_folder_user_id(&self, folder_id: &str) -> Result { - sqlx::query_scalar::<_, String>( - "SELECT user_id FROM storage.folders WHERE id = $1::uuid", - ) - .bind(folder_id) - .fetch_optional(self.pool()) - .await - .map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))? - .ok_or_else(|| DomainError::not_found("Folder", folder_id)) - } -} +//! PostgreSQL-backed folder repository. +//! +//! Implements `FolderRepository` (and thus `FolderStoragePort`) using the +//! `storage.folders` table. Folders are purely virtual — no physical +//! directories are created on the filesystem. + +use async_trait::async_trait; +use sqlx::PgPool; +use std::sync::Arc; + +use crate::common::errors::DomainError; +use crate::domain::entities::folder::Folder; +use crate::domain::repositories::folder_repository::FolderRepository; +use crate::domain::services::path_service::StoragePath; + +/// PostgreSQL-backed folder repository. +/// +/// All folder metadata lives in the `storage.folders` table. The physical +/// filesystem is never touched for folder operations. +pub struct FolderDbRepository { + pool: Option>, +} + +impl FolderDbRepository { + pub fn new(pool: Arc) -> Self { + Self { pool: Some(pool) } + } + + /// Creates a stub instance for `AppState::default()`. + /// This is never called in production — only used for route scaffolding. + pub fn new_stub() -> Self { + Self { pool: None } + } + + /// Get the pool, panicking if stub. + fn pool(&self) -> &PgPool { + self.pool + .as_deref() + .expect("FolderDbRepository: pool not available (stub instance)") + } + + // ── helpers ────────────────────────────────────────────────── + + /// Build the full virtual path for a folder by walking up the `parent_id` chain. + async fn build_folder_path(&self, folder_id: &str) -> Result { + // CTE-based recursive query to build path segments + let _rows = sqlx::query_as::<_, (String,)>( + r#" + WITH RECURSIVE ancestors AS ( + SELECT id, name, parent_id + FROM storage.folders + WHERE id = $1::uuid + UNION ALL + SELECT f.id, f.name, f.parent_id + FROM storage.folders f + JOIN ancestors a ON f.id = a.parent_id + ) + SELECT name FROM ancestors ORDER BY name + "#, + ) + .bind(folder_id) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?; + + // Actually we need a proper ordering. Let me rewrite with depth tracking. + // Re-query with depth. + let rows = sqlx::query_as::<_, (String, i32)>( + r#" + WITH RECURSIVE ancestors AS ( + SELECT id, name, parent_id, 0 AS depth + FROM storage.folders + WHERE id = $1::uuid + UNION ALL + SELECT f.id, f.name, f.parent_id, a.depth + 1 + FROM storage.folders f + JOIN ancestors a ON f.id = a.parent_id + ) + SELECT name, depth FROM ancestors ORDER BY depth DESC + "#, + ) + .bind(folder_id) + .fetch_all(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("path query: {e}")))?; + + let path_parts: Vec<&str> = rows.iter().map(|(name, _)| name.as_str()).collect(); + let path_str = path_parts.join("/"); + Ok(StoragePath::from_string(&path_str)) + } + + /// Convert a database row into a `Folder` domain entity. + async fn row_to_folder( + &self, + id: String, + name: String, + parent_id: Option, + created_at: i64, + modified_at: i64, + ) -> Result { + let storage_path = self.build_folder_path(&id).await?; + Folder::with_timestamps( + id, + name, + storage_path, + parent_id, + created_at as u64, + modified_at as u64, + ) + .map_err(|e| DomainError::internal_error("FolderDb", format!("entity: {e}"))) + } +} + +#[async_trait] +impl FolderRepository for FolderDbRepository { + async fn create_folder( + &self, + name: String, + parent_id: Option, + ) -> Result { + // Derive user_id from parent folder. Root-level folders require the + // caller to have set up the home folder beforehand (done during user + // registration). + let user_id: String = if let Some(ref pid) = parent_id { + sqlx::query_scalar::<_, String>( + "SELECT user_id FROM storage.folders WHERE id = $1::uuid", + ) + .bind(pid) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("parent lookup: {e}")))? + .ok_or_else(|| DomainError::not_found("Folder", pid))? + } else { + return Err(DomainError::internal_error( + "FolderDb", + "Cannot create root folder without user_id — use create_home_folder instead", + )); + }; + + let row = sqlx::query_as::<_, (String, i64, i64)>( + r#" + INSERT INTO storage.folders (name, parent_id, user_id) + VALUES ($1, $2::uuid, $3) + RETURNING id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(&name) + .bind(&parent_id) + .bind(&user_id) + .fetch_one(self.pool()) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.code().as_deref() == Some("23505") { + return DomainError::already_exists( + "Folder", + format!("{name} already exists in parent"), + ); + } + } + DomainError::internal_error("FolderDb", format!("insert: {e}")) + })?; + + self.row_to_folder(row.0, name, parent_id, row.1, row.2) + .await + } + + async fn get_folder(&self, id: &str) -> Result { + let row = sqlx::query_as::<_, (String, String, Option, i64, i64)>( + r#" + SELECT id::text, name, parent_id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE id = $1::uuid AND NOT is_trashed + "#, + ) + .bind(id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("get: {e}")))? + .ok_or_else(|| DomainError::not_found("Folder", id))?; + + self.row_to_folder(row.0, row.1, row.2, row.3, row.4).await + } + + async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result { + // Walk the path segments to find the folder. + let path_str = storage_path.to_string(); + let segments: Vec<&str> = path_str.split('/').filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return Err(DomainError::not_found("Folder", "empty path")); + } + + let mut current_parent: Option = None; + let mut current_id = String::new(); + + for segment in &segments { + let row = if let Some(ref pid) = current_parent { + sqlx::query_as::<_, (String,)>( + r#" + SELECT id::text FROM storage.folders + WHERE name = $1 AND parent_id = $2::uuid AND NOT is_trashed + "#, + ) + .bind(segment) + .bind(pid) + .fetch_optional(self.pool()) + .await + } else { + sqlx::query_as::<_, (String,)>( + r#" + SELECT id::text FROM storage.folders + WHERE name = $1 AND parent_id IS NULL AND NOT is_trashed + "#, + ) + .bind(segment) + .fetch_optional(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("path walk: {e}")))? + .ok_or_else(|| { + DomainError::not_found("Folder", format!("segment '{segment}' in path")) + })?; + + current_id = row.0; + current_parent = Some(current_id.clone()); + } + + self.get_folder(¤t_id).await + } + + async fn list_folders(&self, parent_id: Option<&str>) -> Result, DomainError> { + let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id = $1::uuid AND NOT is_trashed + ORDER BY name + "#, + ) + .bind(pid) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id IS NULL AND NOT is_trashed + ORDER BY name + "#, + ) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("list: {e}")))?; + + let mut folders = Vec::with_capacity(rows.len()); + for (id, name, pid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); + } + Ok(folders) + } + + async fn list_folders_paginated( + &self, + parent_id: Option<&str>, + offset: usize, + limit: usize, + include_total: bool, + ) -> Result<(Vec, Option), DomainError> { + let total = if include_total { + let count: i64 = if let Some(pid) = parent_id { + sqlx::query_scalar( + "SELECT COUNT(*) FROM storage.folders WHERE parent_id = $1::uuid AND NOT is_trashed", + ) + .bind(pid) + .fetch_one(self.pool()) + .await + } else { + sqlx::query_scalar( + "SELECT COUNT(*) FROM storage.folders WHERE parent_id IS NULL AND NOT is_trashed", + ) + .fetch_one(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("count: {e}")))?; + Some(count as usize) + } else { + None + }; + + let rows: Vec<(String, String, Option, i64, i64)> = if let Some(pid) = parent_id { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id = $1::uuid AND NOT is_trashed + ORDER BY name + LIMIT $2 OFFSET $3 + "#, + ) + .bind(pid) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } else { + sqlx::query_as( + r#" + SELECT id::text, name, parent_id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE parent_id IS NULL AND NOT is_trashed + ORDER BY name + LIMIT $1 OFFSET $2 + "#, + ) + .bind(limit as i64) + .bind(offset as i64) + .fetch_all(self.pool()) + .await + } + .map_err(|e| DomainError::internal_error("FolderDb", format!("paginate: {e}")))?; + + let mut folders = Vec::with_capacity(rows.len()); + for (id, name, pid, ca, ma) in rows { + folders.push(self.row_to_folder(id, name, pid, ca, ma).await?); + } + Ok((folders, total)) + } + + async fn rename_folder(&self, id: &str, new_name: String) -> Result { + sqlx::query( + r#" + UPDATE storage.folders + SET name = $1, updated_at = NOW() + WHERE id = $2::uuid AND NOT is_trashed + "#, + ) + .bind(&new_name) + .bind(id) + .execute(self.pool()) + .await + .map_err(|e| { + if let sqlx::Error::Database(ref db_err) = e { + if db_err.code().as_deref() == Some("23505") { + return DomainError::already_exists( + "Folder", + format!("{new_name} already exists"), + ); + } + } + DomainError::internal_error("FolderDb", format!("rename: {e}")) + })?; + + self.get_folder(id).await + } + + async fn move_folder( + &self, + id: &str, + new_parent_id: Option<&str>, + ) -> Result { + sqlx::query( + r#" + UPDATE storage.folders + SET parent_id = $1::uuid, updated_at = NOW() + WHERE id = $2::uuid AND NOT is_trashed + "#, + ) + .bind(new_parent_id) + .bind(id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("move: {e}")))?; + + self.get_folder(id).await + } + + async fn delete_folder(&self, id: &str) -> Result<(), DomainError> { + // Hard delete folder and all descendants (CASCADE handles children) + let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") + .bind(id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("delete: {e}")))?; + + if result.rows_affected() == 0 { + return Err(DomainError::not_found("Folder", id)); + } + Ok(()) + } + + async fn folder_exists(&self, storage_path: &StoragePath) -> Result { + // Try to find by walking the path + match self.get_folder_by_path(storage_path).await { + Ok(_) => Ok(true), + Err(e) if e.to_string().contains("not found") => Ok(false), + Err(e) => Err(e), + } + } + + async fn get_folder_path(&self, id: &str) -> Result { + self.build_folder_path(id).await + } + + // ── Trash operations ── + + async fn move_to_trash(&self, folder_id: &str) -> Result<(), DomainError> { + // Atomic CTE: trash folder + all descendant files in a single statement. + // PostgreSQL executes the entire CTE as one atomic operation — no + // intermediate state where the folder is trashed but files are not. + let result = sqlx::query_scalar::<_, i64>( + r#" + WITH trash_folder AS ( + UPDATE storage.folders + SET is_trashed = TRUE, + trashed_at = NOW(), + original_parent_id = parent_id, + updated_at = NOW() + WHERE id = $1::uuid AND NOT is_trashed + RETURNING id + ), + descendants AS ( + SELECT id FROM trash_folder + UNION ALL + SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id + ), + trash_files AS ( + UPDATE storage.files + SET is_trashed = TRUE, trashed_at = NOW(), original_folder_id = folder_id + WHERE folder_id IN (SELECT id FROM descendants) AND NOT is_trashed + RETURNING 1 + ) + SELECT COUNT(*) FROM trash_folder + "#, + ) + .bind(folder_id) + .fetch_one(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("trash: {e}")))?; + + if result == 0 { + return Err(DomainError::not_found("Folder", folder_id)); + } + + Ok(()) + } + + async fn restore_from_trash( + &self, + folder_id: &str, + _original_path: &str, + ) -> Result<(), DomainError> { + // Atomic CTE: restore folder + all descendant files in a single statement. + let result = sqlx::query_scalar::<_, i64>( + r#" + WITH restore_folder AS ( + UPDATE storage.folders + SET is_trashed = FALSE, + trashed_at = NULL, + parent_id = COALESCE(original_parent_id, parent_id), + original_parent_id = NULL, + updated_at = NOW() + WHERE id = $1::uuid AND is_trashed + RETURNING id + ), + descendants AS ( + SELECT id FROM restore_folder + UNION ALL + SELECT f.id FROM storage.folders f JOIN descendants d ON f.parent_id = d.id + ), + restore_files AS ( + UPDATE storage.files + SET is_trashed = FALSE, + trashed_at = NULL, + folder_id = COALESCE(original_folder_id, folder_id), + original_folder_id = NULL + WHERE folder_id IN (SELECT id FROM descendants) AND is_trashed + RETURNING 1 + ) + SELECT COUNT(*) FROM restore_folder + "#, + ) + .bind(folder_id) + .fetch_one(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("restore: {e}")))?; + + if result == 0 { + return Err(DomainError::not_found("Folder", folder_id)); + } + + Ok(()) + } + + async fn delete_folder_permanently(&self, folder_id: &str) -> Result<(), DomainError> { + // Permanently delete — CASCADE handles children + let result = sqlx::query("DELETE FROM storage.folders WHERE id = $1::uuid") + .bind(folder_id) + .execute(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("perm delete: {e}")))?; + + if result.rows_affected() == 0 { + return Err(DomainError::not_found("Folder", folder_id)); + } + Ok(()) + } +} + +// ── Extra helpers for blob-storage bootstrap ── + +impl FolderDbRepository { + /// Creates a root-level home folder for a user. + /// This is called during user registration. + pub async fn create_home_folder( + &self, + user_id: &str, + name: &str, + ) -> Result { + let row = sqlx::query_as::<_, (String, i64, i64)>( + r#" + INSERT INTO storage.folders (name, parent_id, user_id) + VALUES ($1, NULL, $2) + ON CONFLICT DO NOTHING + RETURNING id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + "#, + ) + .bind(name) + .bind(user_id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("home folder: {e}")))?; + + match row { + Some((id, ca, ma)) => self.row_to_folder(id, name.to_string(), None, ca, ma).await, + None => { + // Already exists — fetch it + let existing = sqlx::query_as::<_, (String, i64, i64)>( + r#" + SELECT id::text, + EXTRACT(EPOCH FROM created_at)::bigint, + EXTRACT(EPOCH FROM updated_at)::bigint + FROM storage.folders + WHERE name = $1 AND user_id = $2 AND parent_id IS NULL + "#, + ) + .bind(name) + .bind(user_id) + .fetch_one(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("home fetch: {e}")))?; + self.row_to_folder(existing.0, name.to_string(), None, existing.1, existing.2) + .await + } + } + } + + /// Returns user_id for a given folder. Used by file repositories. + pub async fn get_folder_user_id(&self, folder_id: &str) -> Result { + sqlx::query_scalar::<_, String>("SELECT user_id FROM storage.folders WHERE id = $1::uuid") + .bind(folder_id) + .fetch_optional(self.pool()) + .await + .map_err(|e| DomainError::internal_error("FolderDb", format!("user_id lookup: {e}")))? + .ok_or_else(|| DomainError::not_found("Folder", folder_id)) + } +} diff --git a/src/infrastructure/repositories/pg/trash_db_repository.rs b/src/infrastructure/repositories/pg/trash_db_repository.rs index 6adf0a17..ea8bc65a 100644 --- a/src/infrastructure/repositories/pg/trash_db_repository.rs +++ b/src/infrastructure/repositories/pg/trash_db_repository.rs @@ -1,191 +1,173 @@ -//! PostgreSQL-backed trash repository. -//! -//! Implements `TrashRepository` using soft-delete columns in `storage.files` -//! and `storage.folders`. There is no separate trash table — trashed items -//! are files/folders with `is_trashed = TRUE`. - -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use sqlx::PgPool; -use std::sync::Arc; -use uuid::Uuid; - -use crate::common::errors::{DomainError, Result}; -use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; -use crate::domain::repositories::trash_repository::TrashRepository; - -/// Default retention period (days) used when computing deletion_date. -const _DEFAULT_RETENTION_DAYS: i64 = 30; - -/// PostgreSQL-backed trash repository using soft-delete flags. -pub struct TrashDbRepository { - pool: Arc, - retention_days: i64, -} - -impl TrashDbRepository { - pub fn new(pool: Arc, retention_days: u32) -> Self { - Self { - pool, - retention_days: retention_days as i64, - } - } - - /// Convert a trash_items view row into a TrashedItem entity. - fn row_to_trashed_item( - &self, - id: Uuid, - name: String, - item_type: String, - user_id: String, - trashed_at: Option>, - ) -> TrashedItem { - let trashed_at = trashed_at.unwrap_or_else(Utc::now); - let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); - - let item_type_enum = match item_type.as_str() { - "folder" => TrashedItemType::Folder, - _ => TrashedItemType::File, - }; - - let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil()); - - // In the soft-delete model, the trash entry ID is the same as the - // original item ID since there is no separate trash table. - TrashedItem::from_raw( - id, // trash entry id (same as original) - id, // original item id - user_uuid, // owner - item_type_enum, - name.clone(), - String::new(), // original_path — not stored separately in soft-delete model - trashed_at, - deletion_date, - ) - } -} - -#[async_trait] -impl TrashRepository for TrashDbRepository { - async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> { - // No-op: the actual flagging is done by FileWritePort::move_to_trash - // or FolderRepository::move_to_trash. This method exists for interface - // compatibility with the TrashService. - Ok(()) - } - - async fn get_trash_items(&self, user_id: &Uuid) -> Result> { - let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( - r#" - SELECT id, name, item_type, user_id, trashed_at - FROM storage.trash_items - WHERE user_id = $1 - ORDER BY trashed_at DESC - "#, - ) - .bind(user_id.to_string()) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("TrashDb", format!("list: {e}")) - })?; - - Ok(rows - .into_iter() - .map(|(id, name, item_type, uid, trashed_at)| { - self.row_to_trashed_item(id, name, item_type, uid, trashed_at) - }) - .collect()) - } - - async fn get_trash_item( - &self, - id: &Uuid, - user_id: &Uuid, - ) -> Result> { - let row = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( - r#" - SELECT id, name, item_type, user_id, trashed_at - FROM storage.trash_items - WHERE id = $1 AND user_id = $2 - "#, - ) - .bind(id) - .bind(user_id.to_string()) - .fetch_optional(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("TrashDb", format!("get: {e}")) - })?; - - Ok(row.map(|(id, name, item_type, uid, trashed_at)| { - self.row_to_trashed_item(id, name, item_type, uid, trashed_at) - })) - } - - async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { - // No-op: the actual restore is done by FileWritePort::restore_from_trash - // or FolderRepository::restore_from_trash. The TrashService also removes - // the index entry — which in the soft-delete model means the flag is - // already cleared. - Ok(()) - } - - async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { - // No-op: the actual delete is done by FileWritePort::delete_file_permanently - // or FolderRepository::delete_folder_permanently. - Ok(()) - } - - async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { - // Delete all trashed files for this user - sqlx::query( - "DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE", - ) - .bind(user_id.to_string()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("TrashDb", format!("clear files: {e}")) - })?; - - // Delete all trashed folders for this user - sqlx::query( - "DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE", - ) - .bind(user_id.to_string()) - .execute(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("TrashDb", format!("clear folders: {e}")) - })?; - - Ok(()) - } - - async fn get_expired_items(&self) -> Result> { - let cutoff = Utc::now() - chrono::Duration::days(self.retention_days); - - let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( - r#" - SELECT id, name, item_type, user_id, trashed_at - FROM storage.trash_items - WHERE trashed_at < $1 - ORDER BY trashed_at ASC - "#, - ) - .bind(cutoff) - .fetch_all(self.pool.as_ref()) - .await - .map_err(|e| { - DomainError::internal_error("TrashDb", format!("expired: {e}")) - })?; - - Ok(rows - .into_iter() - .map(|(id, name, item_type, uid, trashed_at)| { - self.row_to_trashed_item(id, name, item_type, uid, trashed_at) - }) - .collect()) - } -} +//! PostgreSQL-backed trash repository. +//! +//! Implements `TrashRepository` using soft-delete columns in `storage.files` +//! and `storage.folders`. There is no separate trash table — trashed items +//! are files/folders with `is_trashed = TRUE`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use std::sync::Arc; +use uuid::Uuid; + +use crate::common::errors::{DomainError, Result}; +use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType}; +use crate::domain::repositories::trash_repository::TrashRepository; + +/// Default retention period (days) used when computing deletion_date. +const _DEFAULT_RETENTION_DAYS: i64 = 30; + +/// PostgreSQL-backed trash repository using soft-delete flags. +pub struct TrashDbRepository { + pool: Arc, + retention_days: i64, +} + +impl TrashDbRepository { + pub fn new(pool: Arc, retention_days: u32) -> Self { + Self { + pool, + retention_days: retention_days as i64, + } + } + + /// Convert a trash_items view row into a TrashedItem entity. + fn row_to_trashed_item( + &self, + id: Uuid, + name: String, + item_type: String, + user_id: String, + trashed_at: Option>, + ) -> TrashedItem { + let trashed_at = trashed_at.unwrap_or_else(Utc::now); + let deletion_date = trashed_at + chrono::Duration::days(self.retention_days); + + let item_type_enum = match item_type.as_str() { + "folder" => TrashedItemType::Folder, + _ => TrashedItemType::File, + }; + + let user_uuid = Uuid::parse_str(&user_id).unwrap_or_else(|_| Uuid::nil()); + + // In the soft-delete model, the trash entry ID is the same as the + // original item ID since there is no separate trash table. + TrashedItem::from_raw( + id, // trash entry id (same as original) + id, // original item id + user_uuid, // owner + item_type_enum, + name.clone(), + String::new(), // original_path — not stored separately in soft-delete model + trashed_at, + deletion_date, + ) + } +} + +#[async_trait] +impl TrashRepository for TrashDbRepository { + async fn add_to_trash(&self, _item: &TrashedItem) -> Result<()> { + // No-op: the actual flagging is done by FileWritePort::move_to_trash + // or FolderRepository::move_to_trash. This method exists for interface + // compatibility with the TrashService. + Ok(()) + } + + async fn get_trash_items(&self, user_id: &Uuid) -> Result> { + let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( + r#" + SELECT id, name, item_type, user_id, trashed_at + FROM storage.trash_items + WHERE user_id = $1 + ORDER BY trashed_at DESC + "#, + ) + .bind(user_id.to_string()) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("list: {e}")))?; + + Ok(rows + .into_iter() + .map(|(id, name, item_type, uid, trashed_at)| { + self.row_to_trashed_item(id, name, item_type, uid, trashed_at) + }) + .collect()) + } + + async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result> { + let row = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( + r#" + SELECT id, name, item_type, user_id, trashed_at + FROM storage.trash_items + WHERE id = $1 AND user_id = $2 + "#, + ) + .bind(id) + .bind(user_id.to_string()) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("get: {e}")))?; + + Ok(row.map(|(id, name, item_type, uid, trashed_at)| { + self.row_to_trashed_item(id, name, item_type, uid, trashed_at) + })) + } + + async fn restore_from_trash(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { + // No-op: the actual restore is done by FileWritePort::restore_from_trash + // or FolderRepository::restore_from_trash. The TrashService also removes + // the index entry — which in the soft-delete model means the flag is + // already cleared. + Ok(()) + } + + async fn delete_permanently(&self, _id: &Uuid, _user_id: &Uuid) -> Result<()> { + // No-op: the actual delete is done by FileWritePort::delete_file_permanently + // or FolderRepository::delete_folder_permanently. + Ok(()) + } + + async fn clear_trash(&self, user_id: &Uuid) -> Result<()> { + // Delete all trashed files for this user + sqlx::query("DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE") + .bind(user_id.to_string()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("clear files: {e}")))?; + + // Delete all trashed folders for this user + sqlx::query("DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE") + .bind(user_id.to_string()) + .execute(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("clear folders: {e}")))?; + + Ok(()) + } + + async fn get_expired_items(&self) -> Result> { + let cutoff = Utc::now() - chrono::Duration::days(self.retention_days); + + let rows = sqlx::query_as::<_, (Uuid, String, String, String, Option>)>( + r#" + SELECT id, name, item_type, user_id, trashed_at + FROM storage.trash_items + WHERE trashed_at < $1 + ORDER BY trashed_at ASC + "#, + ) + .bind(cutoff) + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| DomainError::internal_error("TrashDb", format!("expired: {e}")))?; + + Ok(rows + .into_iter() + .map(|(id, name, item_type, uid, trashed_at)| { + self.row_to_trashed_item(id, name, item_type, uid, trashed_at) + }) + .collect()) + } +} diff --git a/src/infrastructure/services/dedup_service.rs b/src/infrastructure/services/dedup_service.rs index 072681ba..6bc0ee31 100644 --- a/src/infrastructure/services/dedup_service.rs +++ b/src/infrastructure/services/dedup_service.rs @@ -209,9 +209,7 @@ impl DedupService { } // Atomic write: temp file → rename - let temp_path = self - .temp_root - .join(format!("{}.tmp", uuid::Uuid::new_v4())); + let temp_path = self.temp_root.join(format!("{}.tmp", uuid::Uuid::new_v4())); fs::write(&temp_path, content).await.map_err(|e| { DomainError::internal_error("Dedup", format!("Failed to write temp blob: {}", e)) })?; @@ -258,10 +256,7 @@ impl DedupService { let file_size = fs::metadata(source_path) .await .map_err(|e| { - DomainError::internal_error( - "Dedup", - format!("Failed to get file metadata: {}", e), - ) + DomainError::internal_error("Dedup", format!("Failed to get file metadata: {}", e)) })? .len(); diff --git a/src/interfaces/api/handlers/caldav_handler.rs b/src/interfaces/api/handlers/caldav_handler.rs index 0455be28..3782aea5 100644 --- a/src/interfaces/api/handlers/caldav_handler.rs +++ b/src/interfaces/api/handlers/caldav_handler.rs @@ -171,7 +171,7 @@ async fn handle_propfind( if path.is_empty() { // Root CalDAV path — list user's calendars let calendars = calendar_service - .list_my_calendars_for_user(&user.id) + .list_my_calendars(&user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list calendars: {}", e)))?; @@ -198,13 +198,13 @@ async fn handle_propfind( if parts.len() == 1 { // Calendar collection let calendar = calendar_service - .get_calendar_for_user(calendar_id, &user.id) + .get_calendar(calendar_id, &user.id) .await .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; let events = if depth != "0" { calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .unwrap_or_default() } else { @@ -235,7 +235,7 @@ async fn handle_propfind( let ical_uid = event_file.trim_end_matches(".ics"); let events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; @@ -295,14 +295,14 @@ async fn handle_report( CalDavReportType::CalendarQuery { time_range, .. } => { if let Some((start, end)) = time_range { calendar_service - .get_events_in_range_for_user(calendar_id, *start, *end, &user.id) + .get_events_in_range(calendar_id, *start, *end, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to query events: {}", e)) })? } else { calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| { AppError::internal_error(format!("Failed to list events: {}", e)) @@ -311,7 +311,7 @@ async fn handle_report( } CalDavReportType::CalendarMultiget { hrefs, .. } => { let all_events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; @@ -321,7 +321,7 @@ async fn handle_report( .collect() } CalDavReportType::SyncCollection { .. } => calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?, }; @@ -377,7 +377,7 @@ async fn handle_mkcalendar( }; calendar_service - .create_calendar_for_user(create_dto, &user.id) + .create_calendar(create_dto, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to create calendar: {}", e)))?; @@ -417,7 +417,7 @@ async fn handle_put( let existing = if let Some(ref uid) = ical_uid { let events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .unwrap_or_default(); events.into_iter().find(|e| e.ical_uid == *uid) @@ -428,7 +428,7 @@ async fn handle_put( if let Some(existing_event) = existing { // Update existing event — re-create from iCal for full fidelity calendar_service - .delete_event_for_user(&existing_event.id, &user.id) + .delete_event(&existing_event.id, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to update event: {}", e)))?; @@ -437,7 +437,7 @@ async fn handle_put( ical_data, }; let event = calendar_service - .create_event_from_ical_for_user(create_dto, &user.id) + .create_event_from_ical(create_dto, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to recreate event: {}", e)))?; @@ -453,7 +453,7 @@ async fn handle_put( }; let event = calendar_service - .create_event_from_ical_for_user(create_dto, &user.id) + .create_event_from_ical(create_dto, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to create event: {}", e)))?; @@ -492,12 +492,12 @@ async fn handle_get( if parts.len() < 2 { // GET on calendar collection let events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; let calendar = calendar_service - .get_calendar_for_user(calendar_id, &user.id) + .get_calendar(calendar_id, &user.id) .await .map_err(|e| AppError::not_found(format!("Calendar not found: {}", e)))?; @@ -515,7 +515,7 @@ async fn handle_get( let ical_uid = event_file.trim_end_matches(".ics"); let events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; @@ -602,7 +602,7 @@ async fn handle_delete( if parts.len() < 2 { calendar_service - .delete_calendar_for_user(calendar_id, &user.id) + .delete_calendar(calendar_id, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete calendar: {}", e)))?; } else { @@ -610,7 +610,7 @@ async fn handle_delete( let ical_uid = event_file.trim_end_matches(".ics"); let events = calendar_service - .list_events_for_user(calendar_id, None, None, &user.id) + .list_events(calendar_id, None, None, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to list events: {}", e)))?; @@ -620,7 +620,7 @@ async fn handle_delete( .ok_or_else(|| AppError::not_found(format!("Event not found: {}", ical_uid)))?; calendar_service - .delete_event_for_user(&event.id, &user.id) + .delete_event(&event.id, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to delete event: {}", e)))?; } @@ -675,7 +675,7 @@ async fn handle_proppatch( if update.name.is_some() || update.description.is_some() || update.color.is_some() { calendar_service - .update_calendar_for_user(calendar_id, update, &user.id) + .update_calendar(calendar_id, update, &user.id) .await .map_err(|e| AppError::internal_error(format!("Failed to update calendar: {}", e)))?; } diff --git a/src/interfaces/api/handlers/folder_handler.rs b/src/interfaces/api/handlers/folder_handler.rs index 7e132bb5..006aedf8 100644 --- a/src/interfaces/api/handlers/folder_handler.rs +++ b/src/interfaces/api/handlers/folder_handler.rs @@ -1,19 +1,19 @@ -use std::sync::Arc; -use std::collections::HashMap; use axum::{ - extract::{Path, State, Query}, - http::{StatusCode, header, HeaderName, HeaderValue, Response}, - response::IntoResponse, Json, + extract::{Path, Query, State}, + http::{HeaderName, HeaderValue, Response, StatusCode, header}, + response::IntoResponse, }; +use std::collections::HashMap; +use std::sync::Arc; -use crate::application::services::folder_service::FolderService; -use crate::application::dtos::folder_dto::{CreateFolderDto, RenameFolderDto, MoveFolderDto}; +use crate::application::dtos::folder_dto::{CreateFolderDto, MoveFolderDto, RenameFolderDto}; use crate::application::dtos::pagination::PaginationRequestDto; -use crate::common::errors::ErrorKind; use crate::application::ports::inbound::FolderUseCase; +use crate::application::services::folder_service::FolderService; use crate::common::di::AppState as GlobalAppState; -use crate::interfaces::middleware::auth::{OptionalAuthUser, AuthUser}; +use crate::common::errors::ErrorKind; +use crate::interfaces::middleware::auth::{AuthUser, OptionalAuthUser}; type AppState = Arc; @@ -37,14 +37,16 @@ impl FolderHandler { let home_folder_name = format!("My Folder - {}", auth_user.username); tracing::info!( "create_folder: parent_id is None for user '{}', looking up home folder '{}'", - auth_user.username, home_folder_name + auth_user.username, + home_folder_name ); match service.list_folders(None).await { Ok(folders) => { if let Some(home) = folders.iter().find(|f| f.name == home_folder_name) { tracing::info!( "create_folder: resolved home folder ID '{}' for user '{}'", - home.id, auth_user.username + home.id, + auth_user.username ); dto.parent_id = Some(home.id.clone()); } else { @@ -55,7 +57,10 @@ impl FolderHandler { } } Err(e) => { - tracing::error!("create_folder: failed to list folders for home resolution: {}", e); + tracing::error!( + "create_folder: failed to list folders for home resolution: {}", + e + ); } } } @@ -68,12 +73,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Gets a folder by ID pub async fn get_folder( State(service): State, @@ -86,12 +91,12 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Lists root folders (no parent ID) /// Non-admin users only see their own home folder. pub async fn list_root_folders( @@ -145,18 +150,20 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders(parent_id).await { - Ok(folders) => { - (StatusCode::OK, Json(folders)).into_response() - }, + Ok(folders) => (StatusCode::OK, Json(folders)).into_response(), Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } @@ -172,35 +179,42 @@ impl FolderHandler { Ok(folders) => { // Only filter at root level (parent_id == None) let filtered = if parent_id.is_none() { - folders.into_iter().filter(|f| { - // Skip hidden/system folders - if f.name.starts_with('.') { - return false; - } - // If it's a user home folder, only show if it belongs to this user - if Self::is_user_home_folder(&f.name) { - return Self::folder_belongs_to_user(&f.name, &auth_user.username); - } - // Non-home folders are visible to everyone - true - }).collect() + folders + .into_iter() + .filter(|f| { + // Skip hidden/system folders + if f.name.starts_with('.') { + return false; + } + // If it's a user home folder, only show if it belongs to this user + if Self::is_user_home_folder(&f.name) { + return Self::folder_belongs_to_user(&f.name, &auth_user.username); + } + // Non-home folders are visible to everyone + true + }) + .collect() } else { folders }; (StatusCode::OK, Json(filtered)).into_response() - }, + } Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Lists folders with pagination support (internal helper) async fn list_folders_paginated_inner( service: AppState, @@ -208,23 +222,25 @@ impl FolderHandler { parent_id: Option<&str>, ) -> axum::response::Response { match service.list_folders_paginated(parent_id, &pagination).await { - Ok(paginated_result) => { - (StatusCode::OK, Json(paginated_result)).into_response() - }, + Ok(paginated_result) => (StatusCode::OK, Json(paginated_result)).into_response(), Err(err) => { let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a JSON error response - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Renames a folder pub async fn rename_folder( State(service): State, @@ -239,15 +255,19 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + // Return a proper JSON error response - (status, Json(serde_json::json!({ - "error": err.to_string() - }))).into_response() + ( + status, + Json(serde_json::json!({ + "error": err.to_string() + })), + ) + .into_response() } } } - + /// Moves a folder to a new parent pub async fn move_folder( State(service): State, @@ -262,12 +282,12 @@ impl FolderHandler { ErrorKind::AlreadyExists => StatusCode::CONFLICT, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder (with trash support) pub async fn delete_folder( State(service): State, @@ -281,58 +301,68 @@ impl FolderHandler { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - + (status, err.to_string()).into_response() } } } - + /// Deletes a folder with trash functionality pub async fn delete_folder_with_trash( State(state): State, OptionalAuthUser(auth_user): OptionalAuthUser, Path(id): Path, ) -> impl IntoResponse { - let user_id = auth_user.as_ref().map(|u| u.id.as_str()).unwrap_or("anonymous"); + let user_id = auth_user + .as_ref() + .map(|u| u.id.as_str()) + .unwrap_or("anonymous"); // Check if trash service is available if let Some(trash_service) = &state.trash_service { tracing::info!("Moving folder to trash: {}", id); - + // Try to move to trash first match trash_service.move_to_trash(&id, "folder", user_id).await { Ok(_) => { tracing::info!("Folder successfully moved to trash: {}", id); return StatusCode::NO_CONTENT.into_response(); - }, + } Err(err) => { - tracing::warn!("Could not move folder to trash, falling back to permanent delete: {}", err); + tracing::warn!( + "Could not move folder to trash, falling back to permanent delete: {}", + err + ); // Fall through to regular delete if trash fails } } } - + // Fallback to permanent delete if trash is unavailable or failed let folder_service = &state.applications.folder_service; match folder_service.delete_folder(&id).await { Ok(_) => { tracing::info!("Folder permanently deleted: {}", id); StatusCode::NO_CONTENT.into_response() - }, + } Err(err) => { tracing::error!("Error deleting folder: {}", err); - + let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": format!("Error deleting folder: {}", err) - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": format!("Error deleting folder: {}", err) + })), + ) + .into_response() } } } - + /// Downloads a folder as a ZIP file pub async fn download_folder_zip( State(state): State, @@ -340,67 +370,85 @@ impl FolderHandler { Query(_params): Query>, ) -> impl IntoResponse { tracing::info!("Downloading folder as ZIP: {}", id); - + // Get folder information first to check it exists and get name let folder_service = &state.applications.folder_service; - + match folder_service.get_folder(&id).await { Ok(folder) => { tracing::info!("Preparing ZIP for folder: {} ({})", folder.name, id); - + // Use ZIP service from DI container let zip_service = &state.core.zip_service; - + // Create the ZIP file match zip_service.create_folder_zip(&id, &folder.name).await { Ok(zip_data) => { - tracing::info!("ZIP file created successfully, size: {} bytes", zip_data.len()); - + tracing::info!( + "ZIP file created successfully, size: {} bytes", + zip_data.len() + ); + // Setup headers for download let filename = format!("{}.zip", folder.name); let content_disposition = format!("attachment; filename=\"{}\"", filename); - + // Build response with the ZIP data let mut headers = HashMap::new(); - headers.insert(header::CONTENT_TYPE.to_string(), "application/zip".to_string()); - headers.insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); - headers.insert(header::CONTENT_LENGTH.to_string(), zip_data.len().to_string()); - + headers.insert( + header::CONTENT_TYPE.to_string(), + "application/zip".to_string(), + ); + headers + .insert(header::CONTENT_DISPOSITION.to_string(), content_disposition); + headers.insert( + header::CONTENT_LENGTH.to_string(), + zip_data.len().to_string(), + ); + // Build the response let mut response = Response::builder() .status(StatusCode::OK) .body(axum::body::Body::from(zip_data)) .unwrap(); - + // Add headers to response for (name, value) in headers { response.headers_mut().insert( HeaderName::from_bytes(name.as_bytes()).unwrap(), - HeaderValue::from_str(&value).unwrap() + HeaderValue::from_str(&value).unwrap(), ); } - + response - }, + } Err(err) => { tracing::error!("Error creating ZIP file: {}", err); - (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ - "error": format!("Error creating ZIP file: {}", err) - }))).into_response() + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ + "error": format!("Error creating ZIP file: {}", err) + })), + ) + .into_response() } } - }, + } Err(err) => { tracing::error!("Folder not found: {}", err); let status = match err.kind { ErrorKind::NotFound => StatusCode::NOT_FOUND, _ => StatusCode::INTERNAL_SERVER_ERROR, }; - - (status, Json(serde_json::json!({ - "error": format!("Error finding folder: {}", err) - }))).into_response() + + ( + status, + Json(serde_json::json!({ + "error": format!("Error finding folder: {}", err) + })), + ) + .into_response() } } } -} \ No newline at end of file +}