refactoring hexagonal and clean architecture

This commit is contained in:
Diocrafts
2026-02-08 13:40:23 +01:00
parent 3e4fb67c19
commit a82faa5eaf
101 changed files with 7433 additions and 9721 deletions
+8 -8
View File
@@ -32,14 +32,14 @@ impl Default for AddressBookDto {
impl From<AddressBook> for AddressBookDto {
fn from(book: AddressBook) -> Self {
Self {
id: book.id.to_string(),
name: book.name,
owner_id: book.owner_id,
description: book.description,
color: book.color,
is_public: book.is_public,
created_at: book.created_at,
updated_at: book.updated_at,
id: book.id().to_string(),
name: book.name().to_string(),
owner_id: book.owner_id().to_string(),
description: book.description().map(|s| s.to_string()),
color: book.color().map(|s| s.to_string()),
is_public: book.is_public(),
created_at: *book.created_at(),
updated_at: *book.updated_at(),
}
}
}
+25 -24
View File
@@ -112,26 +112,27 @@ impl Default for ContactDto {
impl From<Contact> for ContactDto {
fn from(contact: Contact) -> Self {
let parts = contact.into_parts();
Self {
id: contact.id.to_string(),
address_book_id: contact.address_book_id.to_string(),
uid: contact.uid,
full_name: contact.full_name,
first_name: contact.first_name,
last_name: contact.last_name,
nickname: contact.nickname,
email: contact.email.into_iter().map(EmailDto::from).collect(),
phone: contact.phone.into_iter().map(PhoneDto::from).collect(),
address: contact.address.into_iter().map(AddressDto::from).collect(),
organization: contact.organization,
title: contact.title,
notes: contact.notes,
photo_url: contact.photo_url,
birthday: contact.birthday,
anniversary: contact.anniversary,
created_at: contact.created_at,
updated_at: contact.updated_at,
etag: contact.etag,
id: parts.id.to_string(),
address_book_id: parts.address_book_id.to_string(),
uid: parts.uid,
full_name: parts.full_name,
first_name: parts.first_name,
last_name: parts.last_name,
nickname: parts.nickname,
email: parts.email.into_iter().map(EmailDto::from).collect(),
phone: parts.phone.into_iter().map(PhoneDto::from).collect(),
address: parts.address.into_iter().map(AddressDto::from).collect(),
organization: parts.organization,
title: parts.title,
notes: parts.notes,
photo_url: parts.photo_url,
birthday: parts.birthday,
anniversary: parts.anniversary,
created_at: parts.created_at,
updated_at: parts.updated_at,
etag: parts.etag,
}
}
}
@@ -193,11 +194,11 @@ pub struct ContactGroupDto {
impl From<ContactGroup> for ContactGroupDto {
fn from(group: ContactGroup) -> Self {
Self {
id: group.id.to_string(),
address_book_id: group.address_book_id.to_string(),
name: group.name,
created_at: group.created_at,
updated_at: group.updated_at,
id: group.id().to_string(),
address_book_id: group.address_book_id().to_string(),
name: group.name().to_string(),
created_at: *group.created_at(),
updated_at: *group.updated_at(),
members_count: None,
}
}
+14 -14
View File
@@ -43,20 +43,20 @@ pub struct UpdateShareDto {
/// Extension methods to convert between DTOs and domain entities
impl ShareDto {
pub fn from_entity(share: &Share, base_url: &str) -> Self {
let url = format!("{}/s/{}", base_url, share.token);
let url = format!("{}/s/{}", base_url, share.token());
Self {
id: share.id.clone(),
item_id: share.item_id.clone(),
item_type: share.item_type.to_string(),
token: share.token.clone(),
id: share.id().to_string(),
item_id: share.item_id().to_string(),
item_type: share.item_type().to_string(),
token: share.token().to_string(),
url,
has_password: share.password_hash.is_some(),
expires_at: share.expires_at,
permissions: SharePermissionsDto::from_entity(&share.permissions),
created_at: share.created_at,
created_by: share.created_by.clone(),
access_count: share.access_count,
has_password: share.has_password(),
expires_at: share.expires_at(),
permissions: SharePermissionsDto::from_entity(share.permissions()),
created_at: share.created_at(),
created_by: share.created_by().to_string(),
access_count: share.access_count(),
}
}
}
@@ -64,9 +64,9 @@ impl ShareDto {
impl SharePermissionsDto {
pub fn from_entity(permissions: &SharePermissions) -> Self {
Self {
read: permissions.read,
write: permissions.write,
reshare: permissions.reshare,
read: permissions.read(),
write: permissions.write(),
reshare: permissions.reshare(),
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Cache Ports — Application-layer abstractions for all caching concerns.
//!
//! This module defines ports (traits) for:
//! - **WriteBehindCachePort**: deferred write caching for zero-latency uploads.
//! - **MetadataCachePort**: file/directory metadata caching (existence, size, timestamps).
//! - **ContentCachePort**: hot file content caching (small files served from RAM).
//!
//! The application and interface layers remain independent of the caching
//! implementation details.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use bytes::Bytes;
use crate::common::errors::DomainError;
/// Statistics for monitoring write-behind cache status.
#[derive(Debug, Clone, Default)]
pub struct WriteBehindStatsDto {
pub pending_count: usize,
pub pending_bytes: usize,
pub total_writes: u64,
pub total_bytes_written: u64,
pub cache_hits: u64,
pub avg_flush_time_us: u64,
}
/// Port for write-behind cache operations.
///
/// Provides deferred write semantics: small files are held in memory
/// and the response is returned immediately, while actual disk writes
/// happen asynchronously in the background.
#[async_trait]
pub trait WriteBehindCachePort: Send + Sync + 'static {
/// Check if a file size is eligible for write-behind caching.
fn is_eligible_size(&self, size: usize) -> bool;
/// Put a file in the pending write cache.
///
/// Returns `Ok(true)` if cached successfully, `Ok(false)` if cache is full.
async fn put_pending(
&self,
file_id: String,
content: Bytes,
target_path: PathBuf,
) -> Result<bool, DomainError>;
/// Get content from cache if the file is still pending flush.
async fn get_pending(&self, file_id: &str) -> Option<Bytes>;
/// Check if a file is pending flush.
async fn is_pending(&self, file_id: &str) -> bool;
/// Force immediate flush of a specific file.
async fn force_flush(&self, file_id: &str) -> Result<(), DomainError>;
/// Flush all pending writes immediately.
async fn flush_all(&self) -> Result<(), DomainError>;
/// Gracefully shutdown the cache, flushing all pending writes.
async fn shutdown(&self) -> Result<(), DomainError>;
/// Get current cache statistics.
async fn get_stats(&self) -> WriteBehindStatsDto;
}
// ─── Metadata Cache ──────────────────────────────────────────
/// Lightweight DTO for cached file/directory metadata.
#[derive(Debug, Clone)]
pub struct CachedMetadataDto {
pub path: PathBuf,
pub exists: bool,
pub is_file: bool,
pub size: Option<u64>,
pub mime_type: Option<String>,
pub created_at: Option<u64>,
pub modified_at: Option<u64>,
}
/// Port for file/directory metadata caching.
///
/// Provides fast lookups for existence, size, timestamps and MIME types
/// without hitting the filesystem on every request.
#[async_trait]
pub trait MetadataCachePort: Send + Sync + 'static {
/// Get cached metadata for a path, or `None` on miss / expired.
async fn get_metadata(&self, path: &Path) -> Option<CachedMetadataDto>;
/// Check whether a path is a file (cached). Returns `None` on miss.
async fn is_file(&self, path: &Path) -> Option<bool>;
/// Read actual filesystem metadata and update the cache entry.
async fn refresh_metadata(&self, path: &Path) -> Result<CachedMetadataDto, DomainError>;
/// Invalidate a single cache entry.
async fn invalidate(&self, path: &Path);
/// Invalidate all entries under a directory (recursive prefix match).
async fn invalidate_directory(&self, dir_path: &Path);
}
// ─── Content Cache ───────────────────────────────────────────
/// Port for hot file content caching (small frequently-accessed files in RAM).
///
/// Implementations should use LRU eviction and respect size limits so that
/// the application layer never needs to know the concrete cache type.
#[async_trait]
pub trait ContentCachePort: Send + Sync + 'static {
/// Check whether a file of the given size should be cached.
fn should_cache(&self, size: usize) -> bool;
/// Get cached content. Returns `(content, etag, content_type)` on hit.
async fn get(&self, file_id: &str) -> Option<(Bytes, String, String)>;
/// Store content in the cache (may evict older entries).
async fn put(&self, file_id: String, content: Bytes, etag: String, content_type: String);
/// Remove a file from the cache (e.g. on delete/update).
async fn invalidate(&self, file_id: &str);
/// Clear the entire cache.
async fn clear(&self);
}
@@ -0,0 +1,110 @@
//! Chunked Upload Port - Application layer abstraction for resumable chunked uploads.
//!
//! This module defines the port (trait) and DTOs for chunked/resumable upload
//! operations, keeping the application and interface layers independent of
//! the specific upload implementation (TUS-like protocol, S3 multipart, etc.).
use std::path::PathBuf;
use async_trait::async_trait;
use bytes::Bytes;
use serde::Serialize;
use crate::common::errors::DomainError;
/// Default chunk size (5 MB) — optimised for parallel transfers.
pub const DEFAULT_CHUNK_SIZE: usize = 5 * 1024 * 1024;
/// Minimum file size to use chunked upload (10 MB).
pub const CHUNKED_UPLOAD_THRESHOLD: usize = 10 * 1024 * 1024;
/// Response returned when a new upload session is created.
#[derive(Debug, Clone, Serialize)]
pub struct CreateUploadResponseDto {
pub upload_id: String,
pub chunk_size: usize,
pub total_chunks: usize,
pub expires_at: u64,
}
/// Response returned after a single chunk is uploaded.
#[derive(Debug, Clone, Serialize)]
pub struct ChunkUploadResponseDto {
pub chunk_index: usize,
pub bytes_received: u64,
pub progress: f64,
pub is_complete: bool,
}
/// Response for querying upload session status.
#[derive(Debug, Clone, Serialize)]
pub struct UploadStatusResponseDto {
pub upload_id: String,
pub filename: String,
pub total_size: u64,
pub bytes_received: u64,
pub progress: f64,
pub total_chunks: usize,
pub completed_chunks: usize,
pub pending_chunks: Vec<usize>,
pub is_complete: bool,
}
/// Port for chunked/resumable upload operations.
///
/// Implementations manage upload sessions, chunk storage, reassembly,
/// and cleanup, while the application layer only interacts through
/// this abstraction.
#[async_trait]
pub trait ChunkedUploadPort: Send + Sync + 'static {
/// Create a new upload session.
///
/// Returns session metadata including the upload ID, chunk size,
/// total number of chunks, and expiration timestamp.
async fn create_session(
&self,
filename: String,
folder_id: Option<String>,
content_type: String,
total_size: u64,
chunk_size: Option<usize>,
) -> Result<CreateUploadResponseDto, DomainError>;
/// Upload a single chunk.
///
/// `checksum` is an optional MD5 hex string for integrity verification.
async fn upload_chunk(
&self,
upload_id: &str,
chunk_index: usize,
data: Bytes,
checksum: Option<String>,
) -> Result<ChunkUploadResponseDto, DomainError>;
/// Get the current status of an upload session.
async fn get_status(
&self,
upload_id: &str,
) -> Result<UploadStatusResponseDto, DomainError>;
/// Assemble all chunks into the final file.
///
/// Returns `(assembled_file_path, filename, folder_id, content_type, total_size)`.
async fn complete_upload(
&self,
upload_id: &str,
) -> Result<(PathBuf, String, Option<String>, String, u64), DomainError>;
/// Finalize upload: clean up the session and temporary files.
async fn finalize_upload(
&self,
upload_id: &str,
) -> Result<(), DomainError>;
/// Cancel an upload and clean up all temporary data.
async fn cancel_upload(
&self,
upload_id: &str,
) -> Result<(), DomainError>;
/// Check if a file size qualifies for chunked upload.
fn should_use_chunked(&self, size: u64) -> bool;
}
@@ -0,0 +1,40 @@
//! Compression Port - Application layer abstraction for compression services.
//!
//! This module defines the port (trait) for compression operations,
//! keeping the application and interface layers independent of specific
//! compression implementations (gzip, zstd, etc.).
use async_trait::async_trait;
use crate::common::errors::DomainError;
/// Compression level settings for file compression operations.
///
/// These levels control the trade-off between compression speed and ratio.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CompressionLevel {
/// No compression (passthrough)
None = 0,
/// Fast compression with lower ratio
Fast = 1,
/// Balanced compression (default)
Default = 6,
/// Maximum compression (slower)
Best = 9,
}
/// Port for compression/decompression operations.
///
/// Implementations of this trait provide the actual compression logic
/// (e.g., gzip, zstd) while the application layer remains agnostic
/// of the specific algorithm used.
#[async_trait]
pub trait CompressionPort: Send + Sync + 'static {
/// Compress data in memory.
async fn compress_data(&self, data: &[u8], level: CompressionLevel) -> Result<Vec<u8>, DomainError>;
/// Decompress data in memory.
async fn decompress_data(&self, compressed_data: &[u8]) -> Result<Vec<u8>, DomainError>;
/// Determine if a file should be compressed based on its MIME type and size.
fn should_compress(&self, mime_type: &str, size: u64) -> bool;
}
+148
View File
@@ -0,0 +1,148 @@
//! Deduplication Port - Application layer abstraction for content-addressable storage.
//!
//! This module defines the port (trait) and DTOs for deduplication operations,
//! keeping the application and interface layers independent of the specific
//! content-addressable storage implementation.
use std::path::{Path, PathBuf};
use async_trait::async_trait;
use bytes::Bytes;
use serde::Serialize;
use crate::common::errors::DomainError;
/// Metadata of a stored blob in the dedup system.
#[derive(Debug, Clone, Serialize)]
pub struct BlobMetadataDto {
/// SHA-256 hash of the content.
pub hash: String,
/// Size in bytes.
pub size: u64,
/// Number of references to this blob.
pub ref_count: u32,
/// Original content type (for serving).
pub content_type: Option<String>,
}
/// Result of a deduplication store operation.
#[derive(Debug, Clone)]
pub enum DedupResultDto {
/// New content was stored (first occurrence).
NewBlob {
hash: String,
size: u64,
blob_path: PathBuf,
},
/// Content already existed; a reference was added instead.
ExistingBlob {
hash: String,
size: u64,
blob_path: PathBuf,
saved_bytes: u64,
},
}
impl DedupResultDto {
pub fn hash(&self) -> &str {
match self {
DedupResultDto::NewBlob { hash, .. } => hash,
DedupResultDto::ExistingBlob { hash, .. } => hash,
}
}
pub fn size(&self) -> u64 {
match self {
DedupResultDto::NewBlob { size, .. } => *size,
DedupResultDto::ExistingBlob { size, .. } => *size,
}
}
pub fn blob_path(&self) -> &Path {
match self {
DedupResultDto::NewBlob { blob_path, .. } => blob_path,
DedupResultDto::ExistingBlob { blob_path, .. } => blob_path,
}
}
pub fn was_deduplicated(&self) -> bool {
matches!(self, DedupResultDto::ExistingBlob { .. })
}
}
/// Statistics for the deduplication service.
#[derive(Debug, Clone, Default, Serialize)]
pub struct DedupStatsDto {
/// Total number of unique blobs.
pub total_blobs: u64,
/// Total bytes stored (actual disk usage).
pub total_bytes_stored: u64,
/// Total bytes referenced (logical size).
pub total_bytes_referenced: u64,
/// Bytes saved through deduplication.
pub bytes_saved: u64,
/// Number of deduplication hits.
pub dedup_hits: u64,
/// Deduplication ratio (referenced / stored).
pub dedup_ratio: f64,
}
/// Port for content-addressable deduplication operations.
///
/// Implementations store files by their content hash, eliminating
/// duplicate storage automatically. Multiple file references can
/// point to the same physical blob.
#[async_trait]
pub trait DedupPort: Send + Sync + 'static {
/// Store content with deduplication (from bytes).
///
/// If content with the same hash already exists, a reference is added
/// instead of storing a duplicate.
async fn store_bytes(
&self,
content: &[u8],
content_type: Option<String>,
) -> Result<DedupResultDto, DomainError>;
/// Store content with deduplication (streaming from file).
async fn store_from_file(
&self,
source_path: &Path,
content_type: Option<String>,
) -> Result<DedupResultDto, DomainError>;
/// Check if a blob with the given hash exists.
async fn blob_exists(&self, hash: &str) -> bool;
/// Get metadata for a blob.
async fn get_blob_metadata(&self, hash: &str) -> Option<BlobMetadataDto>;
/// Read blob content as raw bytes.
async fn read_blob(&self, hash: &str) -> Result<Vec<u8>, DomainError>;
/// Read blob content as `Bytes`.
async fn read_blob_bytes(&self, hash: &str) -> Result<Bytes, DomainError>;
/// Add a reference to a blob (increment ref_count).
async fn add_reference(&self, hash: &str) -> Result<(), DomainError>;
/// Remove a reference from a blob.
///
/// Returns `true` if the blob was deleted (ref_count reached 0).
async fn remove_reference(&self, hash: &str) -> Result<bool, DomainError>;
/// Calculate SHA-256 hash of in-memory content.
fn hash_bytes(&self, content: &[u8]) -> String;
/// Calculate SHA-256 hash of a file (streaming).
async fn hash_file(&self, path: &Path) -> Result<String, DomainError>;
/// Get deduplication statistics.
async fn get_stats(&self) -> DedupStatsDto;
/// Flush the index to persistent storage.
async fn flush(&self) -> Result<(), DomainError>;
/// Verify integrity of all stored blobs.
///
/// Returns a list of issues found (empty if everything is OK).
async fn verify_integrity(&self) -> Result<Vec<String>, DomainError>;
}
+24
View File
@@ -16,4 +16,28 @@ pub trait FavoritesUseCase: Send + Sync {
/// Check if an item is in user's favorites
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
}
// ─────────────────────────────────────────────────────
// Outbound port — persistence abstraction
// ─────────────────────────────────────────────────────
/// Puerto secundario (outbound) para persistencia de favoritos.
///
/// Los servicios de aplicación dependen de este trait en lugar de
/// acceder directamente a `PgPool`. La implementación concreta
/// vive en `infrastructure::repositories::pg`.
#[async_trait]
pub trait FavoritesRepositoryPort: Send + Sync + 'static {
/// Obtiene todos los favoritos de un usuario.
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>>;
/// Añade un ítem a favoritos. Devuelve `Ok(())` si ya existía (idempotente).
async fn add_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>;
/// Elimina un ítem de favoritos. Devuelve `true` si existía.
async fn remove_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
/// Comprueba si un ítem está en favoritos.
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
}
+97
View File
@@ -1,4 +1,5 @@
use std::sync::Arc;
use std::pin::Pin;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
@@ -6,6 +7,21 @@ use futures::Stream;
use crate::application::dtos::file_dto::FileDto;
use crate::common::errors::DomainError;
// ─────────────────────────────────────────────────────
// Upload port
// ─────────────────────────────────────────────────────
/// Strategy chosen by the upload service based on file size.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UploadStrategy {
/// Instant (<256KB): write-behind cache, ~0ms latency
WriteBehind,
/// Buffered (256KB–1MB): full bytes in memory then write
Buffered,
/// Streaming (≥1MB): pipe chunks directly to disk
Streaming,
}
/// Puerto primario para operaciones de subida de archivos
#[async_trait]
pub trait FileUploadUseCase: Send + Sync + 'static {
@@ -17,6 +33,47 @@ pub trait FileUploadUseCase: Send + Sync + 'static {
content_type: String,
content: Vec<u8>,
) -> Result<FileDto, DomainError>;
/// Smart upload: picks the best strategy (write-behind / buffered / streaming)
/// and handles dedup automatically.
///
/// Returns `(FileDto, UploadStrategy)` so the handler can log the chosen tier.
async fn smart_upload(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
chunks: Vec<Bytes>,
total_size: usize,
) -> Result<(FileDto, UploadStrategy), DomainError>;
/// Crea un nuevo archivo en la ruta especificada (para WebDAV)
async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result<FileDto, DomainError>;
/// Actualiza el contenido de un archivo existente (para WebDAV)
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>;
}
// ─────────────────────────────────────────────────────
// Retrieval / download port
// ─────────────────────────────────────────────────────
/// Optimized file content returned by the retrieval service.
///
/// The handler only needs to map each variant to the appropriate HTTP
/// response; all caching / transcoding / mmap decisions happen in the
/// application layer.
pub enum OptimizedFileContent {
/// Small-file content (possibly transcoded / compressed) already in RAM.
Bytes {
data: Bytes,
mime_type: String,
was_transcoded: bool,
},
/// Memory-mapped file (10–100 MB).
Mmap(Bytes),
/// Streaming download for very large files (≥100 MB).
Stream(Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>),
}
/// Puerto primario para operaciones de recuperación de archivos
@@ -25,6 +82,9 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// Obtiene un archivo por su ID
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError>;
/// Obtiene un archivo por su ruta (para WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
/// Lista archivos en una carpeta
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
@@ -33,8 +93,32 @@ pub trait FileRetrievalUseCase: Send + Sync + 'static {
/// Obtiene contenido de archivo como stream (para archivos grandes)
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Optimized multi-tier download.
///
/// Internalises: write-behind lookup → content-cache → WebP transcode →
/// mmap → streaming, returning an `OptimizedFileContent` variant so the
/// handler only builds the HTTP response.
async fn get_file_optimized(
&self,
id: &str,
accept_webp: bool,
prefer_original: bool,
) -> Result<(FileDto, OptimizedFileContent), DomainError>;
/// Range-based streaming for HTTP Range Requests (video seek, resumable DL).
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
}
// ─────────────────────────────────────────────────────
// Management port (delete, move)
// ─────────────────────────────────────────────────────
/// Puerto primario para operaciones de gestión de archivos
#[async_trait]
pub trait FileManagementUseCase: Send + Sync + 'static {
@@ -43,6 +127,19 @@ pub trait FileManagementUseCase: Send + Sync + 'static {
/// Elimina un archivo
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
/// Smart delete: trash-first with dedup reference cleanup.
///
/// 1. Tries to move to trash (soft delete).
/// 2. Falls back to permanent delete if trash unavailable/failed.
/// 3. Decrements the dedup reference count for the content hash.
///
/// Returns `Ok(true)` when trashed, `Ok(false)` when permanently deleted.
async fn delete_with_cleanup(
&self,
id: &str,
user_id: &str,
) -> Result<bool, DomainError>;
}
/// Factory para crear implementaciones de casos de uso de archivos
-51
View File
@@ -1,53 +1,9 @@
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::{CreateFolderDto, FolderDto, MoveFolderDto, RenameFolderDto};
use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
use crate::common::errors::DomainError;
/// Puerto primario para operaciones de archivos
#[async_trait]
pub trait FileUseCase: Send + Sync + 'static {
/// Sube un nuevo archivo desde bytes
async fn upload_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<FileDto, DomainError>;
/// Obtiene un archivo por su ID
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError>;
/// Obtiene un archivo por su ruta (para WebDAV)
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError>;
/// Crea un nuevo archivo en la ruta especificada (para WebDAV)
async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result<FileDto, DomainError>;
/// Actualiza el contenido de un archivo existente (para WebDAV)
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError>;
/// Lista archivos en una carpeta
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError>;
/// Elimina un archivo
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
/// Obtiene contenido de archivo como bytes (para archivos pequeños)
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
/// Obtiene contenido de archivo como stream (para archivos grandes)
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Mueve un archivo a otra carpeta
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError>;
}
/// Puerto primario para operaciones de carpetas
#[async_trait]
pub trait FolderUseCase: Send + Sync + 'static {
@@ -102,11 +58,4 @@ pub trait SearchUseCase: Send + Sync + 'static {
* @return Resultado indicando éxito o error
*/
async fn clear_search_cache(&self) -> Result<(), DomainError>;
}
/// Factory para crear implementaciones de casos de uso
pub trait UseCaseFactory {
fn create_file_use_case(&self) -> Arc<dyn FileUseCase>;
fn create_folder_use_case(&self) -> Arc<dyn FolderUseCase>;
fn create_search_use_case(&self) -> Arc<dyn SearchUseCase>;
}
+8 -1
View File
@@ -1,6 +1,10 @@
pub mod auth_ports;
pub mod cache_ports;
pub mod calendar_ports;
pub mod carddav_ports;
pub mod chunked_upload_ports;
pub mod compression_ports;
pub mod dedup_ports;
pub mod favorites_ports;
pub mod file_ports;
pub mod inbound;
@@ -8,4 +12,7 @@ pub mod outbound;
pub mod recent_ports;
pub mod share_ports;
pub mod storage_ports;
pub mod trash_ports;
pub mod thumbnail_ports;
pub mod transcode_ports;
pub mod trash_ports;
pub mod zip_ports;
+27 -121
View File
@@ -1,13 +1,14 @@
use std::path::PathBuf;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
// Re-export domain repository traits for backward compatibility
pub use crate::domain::repositories::folder_repository::FolderRepository;
use super::storage_ports::{FileReadPort, FileWritePort};
/// Puerto secundario para operaciones de almacenamiento
#[async_trait]
pub trait StoragePort: Send + Sync + 'static {
@@ -24,124 +25,29 @@ pub trait StoragePort: Send + Sync + 'static {
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
}
/// Puerto secundario para persistencia de archivos
#[async_trait]
pub trait FileStoragePort: Send + Sync + 'static {
/// Guarda un nuevo archivo desde bytes
async fn save_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError>;
/// Guarda un archivo desde stream (streaming upload)
/// Escribe chunks directamente al disco sin acumular en memoria
async fn save_file_from_stream(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
) -> Result<File, DomainError>;
/// Obtiene un archivo por su ID
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
/// Lista archivos en una carpeta
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
/// Elimina un archivo
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
/// Obtiene contenido de archivo como bytes
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
/// Obtiene contenido de archivo como stream
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Obtiene un rango de contenido como stream (para HTTP Range Requests)
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Memory-maps archivo para acceso zero-copy (ideal para 10-100MB)
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError>;
/// Mueve un archivo a otra carpeta
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
/// Obtiene la ruta de almacenamiento de un archivo
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
/// Obtiene el ID de la carpeta padre para una ruta dada (necesario para WebDAV)
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
/// Actualiza el contenido de un archivo existente
async fn update_file_content(&self, file_id: &str, content: Vec<u8>) -> Result<(), DomainError>;
/// Registra metadatos de archivo SIN escribir contenido a disco (write-behind)
///
/// Este método:
/// 1. Genera ID único para el archivo
/// 2. Calcula la ruta de destino
/// 3. Registra el mapping ID->path
/// 4. Devuelve File entity con path real (pero archivo no existe aún)
///
/// El contenido se escribe posteriormente via WriteBehindCache
/// Beneficio: Respuesta ~0ms para uploads pequeños
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
) -> Result<(File, PathBuf), DomainError>;
}
/// Puerto unificado para persistencia de archivos (backward-compatible).
///
/// Ahora es un **supertrait** de `FileReadPort + FileWritePort`.
/// Cualquier tipo que implemente ambos ports obtiene `FileStoragePort`
/// automáticamente via blanket impl. Esto permite migrar consumidores
/// gradualmente a los ports granulares mientras los existentes siguen
/// funcionando sin cambios.
pub trait FileStoragePort: FileReadPort + FileWritePort {}
/// Puerto secundario para persistencia de carpetas
#[async_trait]
pub trait FolderStoragePort: Send + Sync + 'static {
/// Crea una nueva carpeta
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError>;
/// Obtiene una carpeta por su ID
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
/// Obtiene una carpeta por su ruta
async fn get_folder_by_path(&self, storage_path: &StoragePath) -> Result<Folder, DomainError>;
/// Lista carpetas dentro de una carpeta padre
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
/// Lista carpetas con paginación
async fn list_folders_paginated(
&self,
parent_id: Option<&str>,
offset: usize,
limit: usize,
include_total: bool
) -> Result<(Vec<Folder>, Option<usize>), DomainError>;
/// Renombra una carpeta
async fn rename_folder(&self, id: &str, new_name: String) -> Result<Folder, DomainError>;
/// Mueve una carpeta a otro padre
async fn move_folder(&self, id: &str, new_parent_id: Option<&str>) -> Result<Folder, DomainError>;
/// Elimina una carpeta
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
/// Verifica si existe una carpeta en la ruta dada
async fn folder_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
/// Obtiene la ruta de una carpeta
async fn get_folder_path(&self, id: &str) -> Result<StoragePath, DomainError>;
}
/// Blanket implementation: cualquier tipo que implemente ambos ports
/// es automáticamente un FileStoragePort.
impl<T: FileReadPort + FileWritePort> FileStoragePort for T {}
/// Puerto secundario para persistencia de carpetas (application layer).
///
/// Tiene la misma firma que `FolderRepository` del dominio.
/// Las implementaciones concretas deben implementar `FolderRepository`,
/// obteniendo `FolderStoragePort` automáticamente vía blanket impl.
pub trait FolderStoragePort: FolderRepository {}
/// Blanket implementation: cualquier tipo que implemente FolderRepository
/// es automáticamente un FolderStoragePort.
impl<T: FolderRepository> FolderStoragePort for T {}
/// Puerto secundario para mapeo de IDs
#[async_trait]
+26
View File
@@ -16,4 +16,30 @@ pub trait RecentItemsUseCase: Send + Sync {
/// Limpiar toda la lista de elementos recientes
async fn clear_recent_items(&self, user_id: &str) -> Result<()>;
}
// ─────────────────────────────────────────────────────
// Outbound port — persistence abstraction
// ─────────────────────────────────────────────────────
/// Puerto secundario (outbound) para persistencia de elementos recientes.
///
/// Abstrae el acceso a la tabla `auth.user_recent_files` para que
/// `RecentService` no dependa directamente de `PgPool`.
#[async_trait]
pub trait RecentItemsRepositoryPort: Send + Sync + 'static {
/// Obtiene los últimos elementos recientes de un usuario (ordenados por fecha desc).
async fn get_recent_items(&self, user_id: &str, limit: i32) -> Result<Vec<RecentItemDto>>;
/// Registra/actualiza el acceso a un ítem (upsert por user+item+type).
async fn upsert_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()>;
/// Elimina un ítem de recientes. Devuelve `true` si existía.
async fn remove_item(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool>;
/// Elimina todos los elementos recientes de un usuario.
async fn clear_all(&self, user_id: &str) -> Result<()>;
/// Elimina elementos que excedan `max_items` (los más antiguos).
async fn prune(&self, user_id: &str, max_items: i32) -> Result<()>;
}
+101 -25
View File
@@ -8,26 +8,65 @@ use crate::domain::entities::file::File;
use crate::domain::services::path_service::StoragePath;
use crate::common::errors::DomainError;
/// Puerto secundario para lectura de archivos
// Re-export domain repository traits for backward compatibility.
// The canonical definitions now live in domain/repositories/.
pub use crate::domain::repositories::file_repository::{FileReadRepository, FileWriteRepository, FileRepository};
pub use crate::domain::repositories::folder_repository::FolderRepository;
// ─────────────────────────────────────────────────────
// FileReadPort — application-layer alias for FileReadRepository
// ─────────────────────────────────────────────────────
/// Puerto secundario para **lectura** de archivos.
///
/// Encapsula toda operación que consulta estado sin modificarlo:
/// get, list, content, stream, mmap, range, resolución de rutas.
#[async_trait]
pub trait FileReadPort: Send + Sync + 'static {
/// Obtiene un archivo por su ID
/// Obtiene un archivo por su ID.
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
/// Lista archivos en una carpeta
/// Lista archivos en una carpeta.
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
/// Obtiene contenido de archivo como bytes
/// Obtiene contenido completo como bytes (solo archivos pequeños/medianos).
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
/// Obtiene contenido de archivo como stream
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Obtiene contenido como stream (ideal para archivos grandes).
async fn get_file_stream(
&self,
id: &str,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Stream de un rango de bytes (HTTP Range Requests, video seek).
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError>;
/// Memory-map de archivo para acceso zero-copy (10–100 MB).
async fn get_file_mmap(&self, id: &str) -> Result<Bytes, DomainError>;
/// Obtiene la ruta de almacenamiento lógica de un archivo.
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
/// Obtiene el ID de la carpeta padre a partir de una ruta (WebDAV).
async fn get_parent_folder_id(&self, path: &str) -> Result<String, DomainError>;
}
/// Puerto secundario para escritura de archivos
// ─────────────────────────────────────────────────────
// FileWritePort — all write / mutate operations
// ─────────────────────────────────────────────────────
/// Puerto secundario para **escritura** de archivos.
///
/// Cubre: upload (buffered + streaming), move, delete, update,
/// y el registro diferido para write-behind cache.
#[async_trait]
pub trait FileWritePort: Send + Sync + 'static {
/// Guarda un nuevo archivo desde bytes
/// Guarda un nuevo archivo desde bytes.
async fn save_file(
&self,
name: String,
@@ -35,26 +74,63 @@ pub trait FileWritePort: Send + Sync + 'static {
content_type: String,
content: Vec<u8>,
) -> Result<File, DomainError>;
/// Mueve un archivo a otra carpeta
async fn move_file(&self, file_id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
/// Elimina un archivo
/// Upload en streaming — escribe chunks a disco sin acumular en RAM.
async fn save_file_from_stream(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
stream: std::pin::Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
) -> Result<File, DomainError>;
/// Mueve un archivo a otra carpeta.
async fn move_file(
&self,
file_id: &str,
target_folder_id: Option<String>,
) -> Result<File, DomainError>;
/// Elimina un archivo.
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
/// Obtiene detalles de una carpeta
async fn get_folder_details(&self, folder_id: &str) -> Result<File, DomainError>;
/// Obtiene la ruta de una carpeta como string
async fn get_folder_path_str(&self, folder_id: &str) -> Result<String, DomainError>;
/// Actualiza el contenido de un archivo existente.
async fn update_file_content(&self, file_id: &str, content: Vec<u8>) -> Result<(), DomainError>;
/// Registra metadatos de archivo SIN escribir contenido a disco (write-behind).
///
/// Devuelve `(File, PathBuf)` donde `PathBuf` es la ruta destino para la
/// escritura diferida que realizará el `WriteBehindCache`.
async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
) -> Result<(File, PathBuf), DomainError>;
// ── Trash operations ──
/// Mueve un archivo a la papelera
async fn move_to_trash(&self, file_id: &str) -> Result<(), DomainError>;
/// Restaura un archivo desde la papelera a su ubicación original
async fn restore_from_trash(&self, file_id: &str, original_path: &str) -> Result<(), DomainError>;
/// Elimina un archivo permanentemente (usado por la papelera)
async fn delete_file_permanently(&self, file_id: &str) -> Result<(), DomainError>;
}
// ─────────────────────────────────────────────────────
// Auxiliary ports (unchanged)
// ─────────────────────────────────────────────────────
/// Puerto secundario para resolución de rutas de archivos
#[async_trait]
pub trait FilePathResolutionPort: Send + Sync + 'static {
/// Obtiene la ruta de almacenamiento de un archivo
async fn get_file_path(&self, id: &str) -> Result<StoragePath, DomainError>;
/// Resuelve una ruta de dominio a una ruta física
fn resolve_path(&self, storage_path: &StoragePath) -> PathBuf;
}
@@ -64,7 +140,7 @@ pub trait FilePathResolutionPort: Send + Sync + 'static {
pub trait StorageVerificationPort: Send + Sync + 'static {
/// Verifica si existe un archivo en la ruta dada
async fn file_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
/// Verifica si existe un directorio en la ruta dada
async fn directory_exists(&self, storage_path: &StoragePath) -> Result<bool, DomainError>;
}
@@ -81,7 +157,7 @@ pub trait DirectoryManagementPort: Send + Sync + 'static {
pub trait StorageUsagePort: Send + Sync + 'static {
/// Actualiza estadísticas de uso de almacenamiento para un usuario
async fn update_user_storage_usage(&self, user_id: &str) -> Result<i64, DomainError>;
/// Actualiza estadísticas de uso de almacenamiento para todos los usuarios
async fn update_all_users_storage_usage(&self) -> Result<(), DomainError>;
}
+91
View File
@@ -0,0 +1,91 @@
//! Thumbnail Port - Application layer abstraction for thumbnail generation.
//!
//! This module defines the port (trait) for thumbnail operations,
//! keeping the application and interface layers independent of specific
//! image processing implementations.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use bytes::Bytes;
use crate::common::errors::DomainError;
/// Thumbnail sizes supported by the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ThumbnailSize {
/// Small icon for file listings (150×150)
Icon,
/// Medium preview for gallery view (400×400)
Preview,
/// Large preview for detail view (800×800)
Large,
}
impl ThumbnailSize {
/// Get the maximum dimension for this size.
pub fn max_dimension(&self) -> u32 {
match self {
ThumbnailSize::Icon => 150,
ThumbnailSize::Preview => 400,
ThumbnailSize::Large => 800,
}
}
/// Get the directory name for this size.
pub fn dir_name(&self) -> &'static str {
match self {
ThumbnailSize::Icon => "icon",
ThumbnailSize::Preview => "preview",
ThumbnailSize::Large => "large",
}
}
/// Get all thumbnail sizes.
pub fn all() -> &'static [ThumbnailSize] {
&[ThumbnailSize::Icon, ThumbnailSize::Preview, ThumbnailSize::Large]
}
}
/// Statistics about the thumbnail cache.
#[derive(Debug, Clone)]
pub struct ThumbnailStatsDto {
pub cached_thumbnails: usize,
pub cache_size_bytes: usize,
pub max_cache_bytes: usize,
}
/// Port for thumbnail generation and retrieval.
///
/// Implementations handle the actual image processing, caching,
/// and storage of thumbnails, while the application layer only
/// interacts through this abstraction.
#[async_trait]
pub trait ThumbnailPort: Send + Sync + 'static {
/// Check if a file is an image that can have thumbnails.
fn is_supported_image(&self, mime_type: &str) -> bool;
/// Get a thumbnail, generating it on-demand if needed.
///
/// Returns the thumbnail bytes in WebP format.
async fn get_thumbnail(
&self,
file_id: &str,
size: ThumbnailSize,
original_path: &Path,
) -> Result<Bytes, DomainError>;
/// Generate all thumbnail sizes for a file in the background.
///
/// Called after file upload to pre-generate thumbnails.
fn generate_all_sizes_background(
self: Arc<Self>,
file_id: String,
original_path: PathBuf,
);
/// Delete all thumbnails for a file.
async fn delete_thumbnails(&self, file_id: &str) -> Result<(), DomainError>;
/// Get cache statistics.
async fn get_stats(&self) -> ThumbnailStatsDto;
}
+106
View File
@@ -0,0 +1,106 @@
//! Image Transcode Port - Application layer abstraction for image transcoding.
//!
//! This module defines the port (trait) for on-demand image format conversion
//! (e.g., JPEG/PNG → WebP), keeping the application and interface layers
//! independent of specific image processing implementations.
use async_trait::async_trait;
use bytes::Bytes;
use crate::common::errors::DomainError;
/// Supported output formats for image transcoding.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum OutputFormat {
/// WebP format — best current browser support with good compression.
WebP,
// Future: Avif, JpegXl
}
impl OutputFormat {
/// Get the file extension for this format.
pub fn extension(&self) -> &'static str {
match self {
OutputFormat::WebP => "webp",
}
}
/// Get the MIME type for this format.
pub fn mime_type(&self) -> &'static str {
match self {
OutputFormat::WebP => "image/webp",
}
}
}
/// Browser image format capabilities detected from the Accept header.
#[derive(Debug)]
pub struct BrowserCapabilities {
pub supports_webp: bool,
pub supports_avif: bool,
}
impl BrowserCapabilities {
/// Parse the HTTP Accept header to determine browser image format support.
pub fn from_accept_header(accept: Option<&str>) -> Self {
let accept = accept.unwrap_or("");
Self {
supports_webp: accept.contains("image/webp"),
supports_avif: accept.contains("image/avif"),
}
}
/// Get the best output format supported by the browser.
pub fn best_format(&self) -> Option<OutputFormat> {
if self.supports_webp {
Some(OutputFormat::WebP)
} else {
None
}
}
}
/// Statistics about transcoding operations.
#[derive(Debug, Default, Clone)]
pub struct TranscodeStatsDto {
pub cache_hits: u64,
pub disk_hits: u64,
pub transcodes: u64,
pub bytes_saved: u64,
pub transcode_errors: u64,
}
/// Port for image transcoding operations.
///
/// Implementations handle the actual image conversion, caching,
/// and format detection, while the application layer only interacts
/// through this abstraction.
#[async_trait]
pub trait ImageTranscodePort: Send + Sync + 'static {
/// Check if a MIME type can be transcoded.
fn can_transcode(&self, mime_type: &str) -> bool;
/// Check if transcoding should be attempted based on file size and type.
fn should_transcode(&self, mime_type: &str, file_size: u64) -> bool;
/// Get a transcoded version of an image.
///
/// Returns `(content, mime_type, was_transcoded)`.
/// If transcoding is not beneficial (output larger than input), returns the
/// original content with `was_transcoded = false`.
async fn get_transcoded(
&self,
file_id: &str,
original_content: &[u8],
original_mime: &str,
target_format: OutputFormat,
) -> Result<(Bytes, String, bool), DomainError>;
/// Invalidate cached transcodes for a file.
async fn invalidate(&self, file_id: &str);
/// Get transcoding statistics.
async fn get_stats(&self) -> TranscodeStatsDto;
/// Clear all caches.
async fn clear_cache(&self) -> Result<(), DomainError>;
}
+24
View File
@@ -0,0 +1,24 @@
//! ZIP Port - Application layer abstraction for ZIP archive creation.
//!
//! This module defines the port (trait) for ZIP operations,
//! keeping the interface layer independent of specific ZIP
//! implementation details.
use async_trait::async_trait;
use crate::common::errors::DomainError;
/// Port for ZIP archive operations.
///
/// Implementations handle the actual ZIP file creation, compression,
/// and recursive folder traversal.
#[async_trait]
pub trait ZipPort: Send + Sync + 'static {
/// Create a ZIP archive containing the contents of a folder (recursively).
///
/// Returns the ZIP file bytes.
async fn create_folder_zip(
&self,
folder_id: &str,
folder_name: &str,
) -> Result<Vec<u8>, DomainError>;
}
+26 -92
View File
@@ -4,7 +4,7 @@ use tokio::sync::Semaphore;
use tracing::info;
use thiserror::Error;
use crate::application::services::file_service::FileService;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileManagementUseCase};
use crate::application::services::folder_service::FolderService;
use crate::common::errors::DomainError;
use crate::common::config::AppConfig;
@@ -59,7 +59,8 @@ pub struct BatchStats {
/// Servicio de operaciones por lotes
pub struct BatchOperationService {
file_service: Arc<FileService>,
file_retrieval: Arc<dyn FileRetrievalUseCase>,
file_management: Arc<dyn FileManagementUseCase>,
folder_service: Arc<FolderService>,
config: AppConfig,
semaphore: Arc<Semaphore>,
@@ -68,7 +69,8 @@ pub struct BatchOperationService {
impl BatchOperationService {
/// Crea una nueva instancia del servicio de operaciones por lotes
pub fn new(
file_service: Arc<FileService>,
file_retrieval: Arc<dyn FileRetrievalUseCase>,
file_management: Arc<dyn FileManagementUseCase>,
folder_service: Arc<FolderService>,
config: AppConfig
) -> Self {
@@ -76,7 +78,8 @@ impl BatchOperationService {
let max_concurrency = config.concurrency.max_concurrent_files;
Self {
file_service,
file_retrieval,
file_management,
folder_service,
config,
semaphore: Arc::new(Semaphore::new(max_concurrency)),
@@ -85,10 +88,11 @@ impl BatchOperationService {
/// Crea una nueva instancia con la configuración por defecto
pub fn default(
file_service: Arc<FileService>,
file_retrieval: Arc<dyn FileRetrievalUseCase>,
file_management: Arc<dyn FileManagementUseCase>,
folder_service: Arc<FolderService>
) -> Self {
Self::new(file_service, folder_service, AppConfig::default())
Self::new(file_retrieval, file_management, folder_service, AppConfig::default())
}
/// Copia múltiples archivos en paralelo
@@ -112,7 +116,7 @@ impl BatchOperationService {
// Definir la operación a realizar para cada archivo
let operations = file_ids.into_iter().map(|file_id| {
let file_service = self.file_service.clone();
let mgmt = self.file_management.clone();
let target_folder = target_folder_id.clone();
let semaphore = self.semaphore.clone();
@@ -120,7 +124,7 @@ impl BatchOperationService {
// Adquirir permiso del semáforo
let permit = semaphore.acquire().await.unwrap();
let copy_result = file_service.move_file(&file_id, target_folder.clone()).await;
let copy_result = mgmt.move_file(&file_id, target_folder.clone()).await;
// Liberar el permiso explícitamente (también se libera al hacer drop)
drop(permit);
@@ -183,7 +187,7 @@ impl BatchOperationService {
// Definir la operación a realizar para cada archivo
let operations = file_ids.into_iter().map(|file_id| {
let file_service = self.file_service.clone();
let mgmt = self.file_management.clone();
let target_folder = target_folder_id.clone();
let semaphore = self.semaphore.clone();
@@ -191,7 +195,7 @@ impl BatchOperationService {
// Adquirir permiso del semáforo
let permit = semaphore.acquire().await.unwrap();
let move_result = file_service.move_file(&file_id, target_folder.clone()).await;
let move_result = mgmt.move_file(&file_id, target_folder.clone()).await;
// Liberar el permiso explícitamente
drop(permit);
@@ -253,7 +257,7 @@ impl BatchOperationService {
// Definir la operación a realizar para cada archivo
let operations = file_ids.into_iter().map(|file_id| {
let file_service = self.file_service.clone();
let mgmt = self.file_management.clone();
let semaphore = self.semaphore.clone();
let id_clone = file_id.clone();
@@ -261,7 +265,7 @@ impl BatchOperationService {
// Adquirir permiso del semáforo
let permit = semaphore.acquire().await.unwrap();
let delete_result = file_service.delete_file(&file_id).await;
let delete_result = mgmt.delete_file(&file_id).await;
// Liberar el permiso explícitamente
drop(permit);
@@ -323,14 +327,14 @@ impl BatchOperationService {
// Definir la operación a realizar para cada archivo
let operations = file_ids.into_iter().map(|file_id| {
let file_service = self.file_service.clone();
let retrieval = self.file_retrieval.clone();
let semaphore = self.semaphore.clone();
async move {
// Adquirir permiso del semáforo
let permit = semaphore.acquire().await.unwrap();
let get_result = file_service.get_file(&file_id).await;
let get_result = retrieval.get_file(&file_id).await;
// Liberar el permiso explícitamente
drop(permit);
@@ -665,87 +669,17 @@ impl BatchOperationService {
mod tests {
use super::*;
use std::sync::Arc;
use tokio::sync::Mutex;
use mockall::predicate::*;
use mockall::mock;
// Crear mocks para los servicios
mock! {
FileSvcMock {}
#[async_trait]
impl FileService for FileSvcMock {
async fn create_file(&self, name: String, folder_id: Option<String>, content_type: String, content: Vec<u8>) -> Result<File, DomainError>;
async fn get_file(&self, id: &str) -> Result<File, DomainError>;
async fn delete_file(&self, id: &str) -> Result<(), DomainError>;
async fn move_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
async fn copy_file(&self, id: &str, target_folder_id: Option<String>) -> Result<File, DomainError>;
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<File>, DomainError>;
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError>;
}
}
mock! {
FolderSvcMock {}
#[async_trait]
impl FolderService for FolderSvcMock {
async fn create_folder(&self, name: String, parent_id: Option<String>) -> Result<Folder, DomainError>;
async fn get_folder(&self, id: &str) -> Result<Folder, DomainError>;
async fn delete_folder(&self, id: &str) -> Result<(), DomainError>;
async fn delete_folder_recursive(&self, id: &str) -> Result<(), DomainError>;
async fn list_folders(&self, parent_id: Option<&str>) -> Result<Vec<Folder>, DomainError>;
async fn move_folder(&self, id: &str, target_parent_id: Option<String>) -> Result<Folder, DomainError>;
}
}
#[tokio::test]
async fn test_batch_delete_files() {
// Crear mocks
let mut file_service = MockFileSvcMock::new();
// Configurar comportamiento esperado
file_service.expect_delete_file()
.times(3)
.returning(|id| {
if id == "error-id" {
Err(DomainError::not_found("FileService", "File not found"))
} else {
Ok(())
}
});
// Crear el servicio de batch con los mocks
let batch_service = BatchOperationService::new(
Arc::new(file_service),
Arc::new(MockFolderSvcMock::new()),
AppConfig::default()
);
// Ejecutar la operación de batch
let file_ids = vec![
"id1".to_string(),
"id2".to_string(),
"error-id".to_string()
];
let result = batch_service.delete_files(file_ids).await.unwrap();
// Verificar los resultados
assert_eq!(result.stats.total, 3);
assert_eq!(result.stats.successful, 2);
assert_eq!(result.stats.failed, 1);
assert_eq!(result.successful.len(), 2);
assert_eq!(result.failed.len(), 1);
assert_eq!(result.failed[0].0, "error-id");
}
use crate::common::stubs::{StubFileRetrievalUseCase, StubFileManagementUseCase};
#[tokio::test]
async fn test_generic_batch_operation() {
// Crear el servicio de batch
// Crear el servicio de batch with stubs
let batch_service = BatchOperationService::new(
Arc::new(MockFileSvcMock::new()),
Arc::new(MockFolderSvcMock::new()),
Arc::new(StubFileRetrievalUseCase),
Arc::new(StubFileManagementUseCase),
Arc::new(FolderService::new(
Arc::new(crate::common::stubs::StubFolderStoragePort)
)),
AppConfig::default()
);
@@ -759,7 +693,7 @@ mod tests {
Ok(item * 2)
} else {
// Simular error para números impares
Err(DomainError::invalid_input("Test", "Odd number not allowed"))
Err(DomainError::validation_error("Odd number not allowed"))
}
};
+118 -141
View File
@@ -44,7 +44,7 @@ impl ContactService {
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
// Check if user is owner
if address_book.owner_id == user_id {
if address_book.owner_id() == user_id {
return Ok(address_book);
}
@@ -55,7 +55,7 @@ impl ContactService {
}
// Check if address book is public
if address_book.is_public {
if address_book.is_public() {
return Ok(address_book);
}
@@ -68,7 +68,7 @@ impl ContactService {
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
// Check if user is owner
if address_book.owner_id == user_id {
if address_book.owner_id() == user_id {
return Ok(address_book);
}
@@ -93,12 +93,12 @@ impl ContactService {
let line = lines[i].trim();
if line.starts_with("FN:") {
contact.full_name = Some(line[3..].to_string());
contact.set_full_name(Some(line[3..].to_string()));
} else if line.starts_with("N:") {
let parts: Vec<&str> = line[2..].split(';').collect();
if parts.len() >= 2 {
contact.last_name = Some(parts[0].to_string());
contact.first_name = Some(parts[1].to_string());
contact.set_last_name(Some(parts[0].to_string()));
contact.set_first_name(Some(parts[1].to_string()));
}
} else if line.starts_with("EMAIL") {
let value = line.split(':').nth(1).unwrap_or("");
@@ -111,10 +111,10 @@ impl ContactService {
"other"
};
contact.email.push(Email {
contact.push_email(Email {
email: value.to_string(),
r#type: email_type.to_string(),
is_primary: contact.email.is_empty(), // First one is primary
is_primary: contact.email_is_empty(), // First one is primary
});
}
} else if line.starts_with("TEL") {
@@ -132,26 +132,26 @@ impl ContactService {
"other"
};
contact.phone.push(Phone {
contact.push_phone(Phone {
number: value.to_string(),
r#type: phone_type.to_string(),
is_primary: contact.phone.is_empty(), // First one is primary
is_primary: contact.phone_is_empty(), // First one is primary
});
}
} else if line.starts_with("ORG:") {
contact.organization = Some(line[4..].to_string());
contact.set_organization(Some(line[4..].to_string()));
} else if line.starts_with("TITLE:") {
contact.title = Some(line[6..].to_string());
contact.set_title(Some(line[6..].to_string()));
} else if line.starts_with("NOTE:") {
contact.notes = Some(line[5..].to_string());
contact.set_notes(Some(line[5..].to_string()));
} else if line.starts_with("UID:") {
contact.uid = line[4..].to_string();
contact.set_uid(line[4..].to_string());
}
}
// Store the original vCard data
contact.vcard = vcard_data.to_string();
contact.etag = Uuid::new_v4().to_string();
contact.set_vcard(vcard_data.to_string());
contact.set_etag(Uuid::new_v4().to_string());
Ok(contact)
}
@@ -160,26 +160,26 @@ impl ContactService {
let mut vcard = String::from("BEGIN:VCARD\r\nVERSION:3.0\r\n");
// UID
vcard.push_str(&format!("UID:{}\r\n", contact.uid));
vcard.push_str(&format!("UID:{}\r\n", contact.uid()));
// Name fields
if let Some(full_name) = &contact.full_name {
if let Some(full_name) = contact.full_name() {
vcard.push_str(&format!("FN:{}\r\n", full_name));
}
let last_name = contact.last_name.clone().unwrap_or_default();
let first_name = contact.first_name.clone().unwrap_or_default();
let last_name = contact.last_name().unwrap_or_default().to_string();
let first_name = contact.first_name().unwrap_or_default().to_string();
vcard.push_str(&format!("N:{};{};;;\r\n", last_name, first_name));
// Email addresses
for email in &contact.email {
for email in contact.email() {
vcard.push_str(&format!("EMAIL;TYPE={}:{}\r\n",
email.r#type.to_uppercase(),
email.email));
}
// Phone numbers
for phone in &contact.phone {
for phone in contact.phone() {
let tel_type = match phone.r#type.as_str() {
"mobile" => "CELL",
"home" => "HOME",
@@ -191,7 +191,7 @@ impl ContactService {
}
// Addresses
for addr in &contact.address {
for addr in contact.address() {
let addr_type = addr.r#type.to_uppercase();
let street = addr.street.clone().unwrap_or_default();
let city = addr.city.clone().unwrap_or_default();
@@ -204,27 +204,27 @@ impl ContactService {
}
// Organization
if let Some(org) = &contact.organization {
if let Some(org) = contact.organization() {
vcard.push_str(&format!("ORG:{}\r\n", org));
}
// Title
if let Some(title) = &contact.title {
if let Some(title) = contact.title() {
vcard.push_str(&format!("TITLE:{}\r\n", title));
}
// Notes
if let Some(notes) = &contact.notes {
if let Some(notes) = contact.notes() {
vcard.push_str(&format!("NOTE:{}\r\n", notes));
}
// Birthday
if let Some(birthday) = &contact.birthday {
if let Some(birthday) = contact.birthday() {
vcard.push_str(&format!("BDAY:{}\r\n", birthday.format("%Y%m%d")));
}
// Revision (last update)
vcard.push_str(&format!("REV:{}\r\n", contact.updated_at.format("%Y%m%dT%H%M%SZ")));
vcard.push_str(&format!("REV:{}\r\n", contact.updated_at().format("%Y%m%dT%H%M%SZ")));
vcard.push_str("END:VCARD\r\n");
@@ -235,19 +235,13 @@ impl ContactService {
#[async_trait]
impl AddressBookUseCase for ContactService {
async fn create_address_book(&self, dto: CreateAddressBookDto) -> Result<AddressBookDto, DomainError> {
let id = Uuid::new_v4();
let now = Utc::now();
let address_book = AddressBook {
id,
name: dto.name,
owner_id: dto.owner_id,
description: dto.description,
color: dto.color,
is_public: dto.is_public.unwrap_or(false),
created_at: now,
updated_at: now,
};
let address_book = AddressBook::new(
dto.name,
dto.owner_id,
dto.description,
dto.color,
dto.is_public.unwrap_or(false),
);
let created_address_book = self.address_book_repository.create_address_book(address_book).await?;
Ok(AddressBookDto::from(created_address_book))
@@ -261,16 +255,16 @@ impl AddressBookUseCase for ContactService {
let address_book = self.check_address_book_write_access(&id, &update.user_id).await?;
// Apply updates
let updated_address_book = AddressBook {
let updated_address_book = AddressBook::from_raw(
id,
name: update.name.unwrap_or(address_book.name),
owner_id: address_book.owner_id,
description: update.description.or(address_book.description),
color: update.color.or(address_book.color),
is_public: update.is_public.unwrap_or(address_book.is_public),
created_at: address_book.created_at,
updated_at: Utc::now(),
};
update.name.unwrap_or_else(|| address_book.name().to_string()),
address_book.owner_id().to_string(),
update.description.or_else(|| address_book.description().map(|s| s.to_string())),
update.color.or_else(|| address_book.color().map(|s| s.to_string())),
update.is_public.unwrap_or(address_book.is_public()),
*address_book.created_at(),
Utc::now(),
);
let result = self.address_book_repository.update_address_book(updated_address_book).await?;
Ok(AddressBookDto::from(result))
@@ -285,7 +279,7 @@ impl AddressBookUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
if address_book.owner_id != user_id {
if address_book.owner_id() != user_id {
return Err(DomainError::unauthorized("Only the owner can delete an address book"));
}
@@ -315,16 +309,16 @@ impl AddressBookUseCase for ContactService {
let mut address_book_map = std::collections::HashMap::new();
for address_book in owned_address_books {
address_book_map.insert(address_book.id, address_book);
address_book_map.insert(*address_book.id(), address_book);
}
for address_book in shared_address_books {
address_book_map.insert(address_book.id, address_book);
address_book_map.insert(*address_book.id(), address_book);
}
for address_book in public_address_books {
if address_book.owner_id != user_id && !address_book_map.contains_key(&address_book.id) {
address_book_map.insert(address_book.id, address_book);
if address_book.owner_id() != user_id && !address_book_map.contains_key(address_book.id()) {
address_book_map.insert(*address_book.id(), address_book);
}
}
@@ -351,7 +345,7 @@ impl AddressBookUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
if address_book.owner_id != user_id {
if address_book.owner_id() != user_id {
return Err(DomainError::unauthorized("Only the owner can share an address book"));
}
@@ -373,7 +367,7 @@ impl AddressBookUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
if address_book.owner_id != user_id {
if address_book.owner_id() != user_id {
return Err(DomainError::unauthorized("Only the owner can unshare an address book"));
}
@@ -390,7 +384,7 @@ impl AddressBookUseCase for ContactService {
.await?
.ok_or_else(|| DomainError::not_found("Address book", "not found"))?;
if address_book.owner_id != user_id {
if address_book.owner_id() != user_id {
return Err(DomainError::unauthorized("Only the owner can view address book shares"));
}
@@ -408,10 +402,6 @@ impl ContactUseCase for ContactService {
// Check if user has write access to the address book
self.check_address_book_write_access(&address_book_id, &dto.user_id).await?;
let id = Uuid::new_v4();
let now = Utc::now();
let uid = format!("{}@oxicloud", id);
// Convert DTOs to domain entities
let email: Vec<Email> = dto.email.into_iter()
.map(|e| Email {
@@ -441,33 +431,28 @@ impl ContactUseCase for ContactService {
})
.collect();
let contact = Contact {
id,
let mut contact = Contact::new(
address_book_id,
uid,
full_name: dto.full_name,
first_name: dto.first_name,
last_name: dto.last_name,
nickname: dto.nickname,
dto.full_name,
dto.first_name,
dto.last_name,
dto.nickname,
email,
phone,
address,
organization: dto.organization,
title: dto.title,
notes: dto.notes,
photo_url: dto.photo_url,
birthday: dto.birthday,
anniversary: dto.anniversary,
vcard: String::new(), // Will be generated after creation
etag: Uuid::new_v4().to_string(),
created_at: now,
updated_at: now,
};
dto.organization,
dto.title,
dto.notes,
dto.photo_url,
dto.birthday,
dto.anniversary,
String::new(), // Will be generated after creation
);
// Generate vCard data
let vcard = self.generate_vcard(&contact);
let mut contact_with_vcard = contact;
contact_with_vcard.vcard = vcard;
contact.set_vcard(vcard);
let contact_with_vcard = contact;
// Create the contact
let created_contact = self.contact_repository.create_contact(contact_with_vcard).await?;
@@ -485,17 +470,12 @@ impl ContactUseCase for ContactService {
let mut contact = self.parse_vcard(&dto.vcard)?;
// Set address book ID
contact.address_book_id = address_book_id;
// Generate a new ID if needed
if contact.id == Uuid::nil() {
contact.id = Uuid::new_v4();
}
contact.set_address_book_id(address_book_id);
// The contact was created with Contact::default() which generates a new ID
// Set creation and update timestamps
let now = Utc::now();
contact.created_at = now;
contact.updated_at = now;
contact.set_updated_at(now);
// Create the contact
let created_contact = self.contact_repository.create_contact(contact).await?;
@@ -512,7 +492,10 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&contact.address_book_id, &update.user_id).await?;
self.check_address_book_write_access(contact.address_book_id(), &update.user_id).await?;
// Destructure contact into owned parts for updates
let parts = contact.into_parts();
// Convert DTO fields to domain entities
let email = if let Some(email_dtos) = update.email {
@@ -524,7 +507,7 @@ impl ContactUseCase for ContactService {
})
.collect()
} else {
contact.email
parts.email
};
let phone = if let Some(phone_dtos) = update.phone {
@@ -536,7 +519,7 @@ impl ContactUseCase for ContactService {
})
.collect()
} else {
contact.phone
parts.phone
};
let address = if let Some(address_dtos) = update.address {
@@ -552,37 +535,37 @@ impl ContactUseCase for ContactService {
})
.collect()
} else {
contact.address
parts.address
};
// Update the contact object
let updated_contact = Contact {
let mut updated_contact = Contact::from_raw(
id,
address_book_id: contact.address_book_id,
uid: contact.uid,
full_name: update.full_name.or(contact.full_name),
first_name: update.first_name.or(contact.first_name),
last_name: update.last_name.or(contact.last_name),
nickname: update.nickname.or(contact.nickname),
parts.address_book_id,
parts.uid,
update.full_name.or(parts.full_name),
update.first_name.or(parts.first_name),
update.last_name.or(parts.last_name),
update.nickname.or(parts.nickname),
email,
phone,
address,
organization: update.organization.or(contact.organization),
title: update.title.or(contact.title),
notes: update.notes.or(contact.notes),
photo_url: update.photo_url.or(contact.photo_url),
birthday: update.birthday.or(contact.birthday),
anniversary: update.anniversary.or(contact.anniversary),
vcard: contact.vcard, // Will be regenerated
etag: Uuid::new_v4().to_string(), // Generate new ETag
created_at: contact.created_at,
updated_at: Utc::now(),
};
update.organization.or(parts.organization),
update.title.or(parts.title),
update.notes.or(parts.notes),
update.photo_url.or(parts.photo_url),
update.birthday.or(parts.birthday),
update.anniversary.or(parts.anniversary),
parts.vcard, // Will be regenerated
Uuid::new_v4().to_string(), // Generate new ETag
parts.created_at,
Utc::now(),
);
// Generate new vCard data
let vcard = self.generate_vcard(&updated_contact);
let mut contact_with_vcard = updated_contact;
contact_with_vcard.vcard = vcard;
updated_contact.set_vcard(vcard);
let contact_with_vcard = updated_contact;
// Update the contact
let result = self.contact_repository.update_contact(contact_with_vcard).await?;
@@ -599,7 +582,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&contact.address_book_id, user_id).await?;
self.check_address_book_write_access(contact.address_book_id(), user_id).await?;
// Delete the contact
self.contact_repository.delete_contact(&id).await?;
@@ -616,7 +599,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has access to the address book
self.check_address_book_access(&contact.address_book_id, user_id).await?;
self.check_address_book_access(contact.address_book_id(), user_id).await?;
Ok(ContactDto::from(contact))
}
@@ -656,16 +639,10 @@ impl ContactUseCase for ContactService {
// Check if user has write access to the address book
self.check_address_book_write_access(&address_book_id, &dto.user_id).await?;
let id = Uuid::new_v4();
let now = Utc::now();
let group = ContactGroup {
id,
let group = ContactGroup::new(
address_book_id,
name: dto.name,
created_at: now,
updated_at: now,
};
dto.name,
);
let created_group = self.contact_group_repository.create_group(group).await?;
Ok(ContactGroupDto::from(created_group))
@@ -681,16 +658,16 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&group.address_book_id, &update.user_id).await?;
self.check_address_book_write_access(group.address_book_id(), &update.user_id).await?;
// Update the group
let updated_group = ContactGroup {
let updated_group = ContactGroup::from_raw(
id,
address_book_id: group.address_book_id,
name: update.name,
created_at: group.created_at,
updated_at: Utc::now(),
};
*group.address_book_id(),
update.name,
*group.created_at(),
Utc::now(),
);
let result = self.contact_group_repository.update_group(updated_group).await?;
Ok(ContactGroupDto::from(result))
@@ -706,7 +683,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
// Delete the group
self.contact_group_repository.delete_group(&id).await?;
@@ -723,7 +700,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has access to the address book
self.check_address_book_access(&group.address_book_id, user_id).await?;
self.check_address_book_access(group.address_book_id(), user_id).await?;
// Get the number of contacts in the group
let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?;
@@ -761,7 +738,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
// Add contact to group
self.contact_group_repository.add_contact_to_group(&group_id, &contact_id).await?;
@@ -781,7 +758,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has write access to the address book
self.check_address_book_write_access(&group.address_book_id, user_id).await?;
self.check_address_book_write_access(group.address_book_id(), user_id).await?;
// Remove contact from group
self.contact_group_repository.remove_contact_from_group(&group_id, &contact_id).await?;
@@ -798,7 +775,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact group", "not found"))?;
// Check if user has access to the address book
self.check_address_book_access(&group.address_book_id, user_id).await?;
self.check_address_book_access(group.address_book_id(), user_id).await?;
// Get contacts in group
let contacts = self.contact_group_repository.get_contacts_in_group(&id).await?;
@@ -817,7 +794,7 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has access to the address book
self.check_address_book_access(&contact.address_book_id, user_id).await?;
self.check_address_book_access(contact.address_book_id(), user_id).await?;
// Get groups for contact
let groups = self.contact_group_repository.get_groups_for_contact(&id).await?;
@@ -836,10 +813,10 @@ impl ContactUseCase for ContactService {
.ok_or_else(|| DomainError::not_found("Contact", "not found"))?;
// Check if user has access to the address book
self.check_address_book_access(&contact.address_book_id, user_id).await?;
self.check_address_book_access(contact.address_book_id(), user_id).await?;
// Return the vCard data
Ok(contact.vcard)
Ok(contact.vcard().to_string())
}
async fn get_contacts_as_vcards(&self, address_book_id: &str, user_id: &str) -> Result<Vec<(String, String)>, DomainError> {
@@ -854,7 +831,7 @@ impl ContactUseCase for ContactService {
// Convert to Vec<(id, vcard)>
let vcards = contacts.into_iter()
.map(|contact| (contact.id.to_string(), contact.vcard))
.map(|contact| (contact.id().to_string(), contact.vcard().to_string()))
.collect();
Ok(vcards)
+22 -146
View File
@@ -1,21 +1,22 @@
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use tracing::{info, error};
use uuid::Uuid;
use tracing::info;
use crate::common::errors::{Result, DomainError, ErrorKind};
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::favorites_ports::{FavoritesUseCase, FavoritesRepositoryPort};
use crate::application::dtos::favorites_dto::FavoriteItemDto;
/// Implementation of the FavoritesUseCase for managing user favorites
/// Implementation of the FavoritesUseCase for managing user favorites.
///
/// Depends on `FavoritesRepositoryPort` (outbound port) instead of
/// accessing the database directly, following hexagonal architecture.
pub struct FavoritesService {
db_pool: Arc<PgPool>,
repo: Arc<dyn FavoritesRepositoryPort>,
}
impl FavoritesService {
/// Create a new FavoritesService with the given database pool
pub fn new(db_pool: Arc<PgPool>) -> Self {
Self { db_pool }
/// Create a new FavoritesService with the given repository port
pub fn new(repo: Arc<dyn FavoritesRepositoryPort>) -> Self {
Self { repo }
}
}
@@ -24,168 +25,43 @@ impl FavoritesUseCase for FavoritesService {
/// Get all favorites for a user
async fn get_favorites(&self, user_id: &str) -> Result<Vec<FavoriteItemDto>> {
info!("Getting favorites for user: {}", user_id);
// Parse user ID as UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Execute raw query to avoid sqlx macros issues
let rows = sqlx::query(
r#"
SELECT
id::TEXT as "id",
user_id::TEXT as "user_id",
item_id as "item_id",
item_type as "item_type",
created_at as "created_at"
FROM auth.user_favorites
WHERE user_id = $1::TEXT
ORDER BY created_at DESC
"#
)
.bind(user_uuid)
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error fetching favorites: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to fetch favorites: {}", e)
)
})?;
// Map rows to DTOs
let mut favorites = Vec::with_capacity(rows.len());
for row in rows {
favorites.push(FavoriteItemDto {
id: row.get("id"),
user_id: row.get("user_id"),
item_id: row.get("item_id"),
item_type: row.get("item_type"),
created_at: row.get("created_at"),
});
}
let favorites = self.repo.get_favorites(user_id).await?;
info!("Retrieved {} favorites for user {}", favorites.len(), user_id);
Ok(favorites)
}
/// Add an item to user's favorites
async fn add_to_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
info!("Adding {} '{}' to favorites for user {}", item_type, item_id, user_id);
// Validate item_type
if item_type != "file" && item_type != "folder" {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"Favorites",
"Item type must be 'file' or 'folder'"
"Item type must be 'file' or 'folder'",
));
}
// Parse user ID as UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Execute raw query to avoid sqlx macros issues
sqlx::query(
r#"
INSERT INTO auth.user_favorites (user_id, item_id, item_type)
VALUES ($1::TEXT, $2, $3)
ON CONFLICT (user_id, item_id, item_type) DO NOTHING
"#
)
.bind(user_uuid)
.bind(item_id)
.bind(item_type)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error adding favorite: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to add to favorites: {}", e)
)
})?;
self.repo.add_favorite(user_id, item_id, item_type).await?;
info!("Successfully added {} '{}' to favorites for user {}", item_type, item_id, user_id);
Ok(())
}
/// Remove an item from user's favorites
async fn remove_from_favorites(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
info!("Removing {} '{}' from favorites for user {}", item_type, item_id, user_id);
// Parse user ID as UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Execute raw query to avoid sqlx macros issues
let result = sqlx::query(
r#"
DELETE FROM auth.user_favorites
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
"#
)
.bind(user_uuid)
.bind(item_id)
.bind(item_type)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error removing favorite: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to remove from favorites: {}", e)
)
})?;
let removed = result.rows_affected() > 0;
let removed = self.repo.remove_favorite(user_id, item_id, item_type).await?;
info!(
"{} {} '{}' from favorites for user {}",
"{} {} '{}' from favorites for user {}",
if removed { "Successfully removed" } else { "Did not find" },
item_type,
item_id,
user_id
item_type, item_id, user_id
);
Ok(removed)
}
/// Check if an item is in user's favorites
async fn is_favorite(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
info!("Checking if {} '{}' is favorite for user {}", item_type, item_id, user_id);
// Parse user ID as UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Execute raw query to avoid sqlx macros issues
let row = sqlx::query(
r#"
SELECT EXISTS (
SELECT 1 FROM auth.user_favorites
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
) AS "is_favorite"
"#
)
.bind(user_uuid)
.bind(item_id)
.bind(item_type)
.fetch_one(&*self.db_pool)
.await
.map_err(|e| {
error!("Database error checking favorite status: {}", e);
DomainError::new(
ErrorKind::InternalError,
"Favorites",
format!("Failed to check favorite status: {}", e)
)
})?;
// Get the boolean value from the row
let is_favorite: bool = row.try_get("is_favorite")
.unwrap_or(false);
Ok(is_favorite)
self.repo.is_favorite(user_id, item_id, item_type).await
}
}
@@ -3,39 +3,155 @@ use async_trait::async_trait;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileManagementUseCase;
use crate::application::ports::storage_ports::FileWritePort;
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
use crate::application::ports::dedup_ports::DedupPort;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::errors::DomainError;
use tracing::{debug, info, warn, error};
/// Service for file management operations
/// Service for file management operations (move, delete).
///
/// The `delete_with_cleanup` method internalises:
/// 1. Content-hash computation for dedup tracking
/// 2. Trash-first soft-delete
/// 3. Fallback to permanent delete
/// 4. Dedup reference-count decrement
pub struct FileManagementService {
file_repository: Arc<dyn FileWritePort>,
file_read: Option<Arc<dyn FileReadPort>>,
trash_service: Option<Arc<dyn TrashUseCase>>,
dedup_service: Option<Arc<dyn DedupPort>>,
}
impl FileManagementService {
/// Creates a new file management service
/// Backward-compatible constructor (no trash, no dedup).
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
Self { file_repository }
Self {
file_repository,
file_read: None,
trash_service: None,
dedup_service: None,
}
}
/// Full constructor with trash + dedup ports.
pub fn new_full(
file_repository: Arc<dyn FileWritePort>,
file_read: Arc<dyn FileReadPort>,
trash_service: Option<Arc<dyn TrashUseCase>>,
dedup_service: Arc<dyn DedupPort>,
) -> Self {
Self {
file_repository,
file_read: Some(file_read),
trash_service,
dedup_service: Some(dedup_service),
}
}
/// Setter for late-bound trash service.
pub fn with_trash_service(mut self, trash_service: Arc<dyn TrashUseCase>) -> Self {
self.trash_service = Some(trash_service);
self
}
// ── private helpers ──────────────────────────────────────────
/// Compute the content hash for dedup tracking. Returns `None` on failure.
async fn compute_content_hash(&self, id: &str) -> Option<String> {
let dedup = self.dedup_service.as_ref()?;
let file_read = self.file_read.as_ref()?;
match file_read.get_file_content(id).await {
Ok(content) => {
let hash = dedup.hash_bytes(&content);
debug!("🔗 DEDUP: File {} has content hash: {}", id, &hash[..12]);
Some(hash)
}
Err(e) => {
debug!("Could not read file content for dedup: {}", e);
None
}
}
}
/// Decrement dedup reference count; log result.
async fn decrement_dedup_ref(&self, hash: &str) {
let Some(dedup) = &self.dedup_service else { return };
match dedup.remove_reference(hash).await {
Ok(true) => info!("🗑️ DEDUP: Blob {} deleted (no more references)", &hash[..12]),
Ok(false) => debug!("🔗 DEDUP: Reference removed from blob {}", &hash[..12]),
Err(e) => warn!("⚠️ DEDUP: Failed to decrement reference: {}", e),
}
}
}
#[async_trait]
impl FileManagementUseCase for FileManagementService {
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError> {
tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
let moved_file = self.file_repository.move_file(file_id, folder_id).await
.map_err(|e| {
tracing::error!("Error moving file (ID: {}): {}", file_id, e);
e
})?;
tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}",
moved_file.name(), moved_file.id(), moved_file.folder_id());
async fn move_file(
&self,
file_id: &str,
folder_id: Option<String>,
) -> Result<FileDto, DomainError> {
info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
let moved_file = self.file_repository.move_file(file_id, folder_id).await.map_err(|e| {
error!("Error moving file (ID: {}): {}", file_id, e);
e
})?;
info!(
"File moved successfully: {} (ID: {}) to folder: {:?}",
moved_file.name(),
moved_file.id(),
moved_file.folder_id()
);
Ok(FileDto::from(moved_file))
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
self.file_repository.delete_file(id).await
}
/// Smart delete: trash-first with dedup reference cleanup.
async fn delete_with_cleanup(
&self,
id: &str,
user_id: &str,
) -> Result<bool, DomainError> {
// Step 1: Compute content hash for dedup tracking
let content_hash = self.compute_content_hash(id).await;
// Step 2: Try trash (soft delete)
if let Some(trash) = &self.trash_service {
info!("Moving file to trash: {}", id);
match trash.move_to_trash(id, "file", user_id).await {
Ok(_) => {
info!("File successfully moved to trash: {}", id);
if let Some(hash) = &content_hash {
self.decrement_dedup_ref(hash).await;
}
return Ok(true); // trashed
}
Err(err) => {
error!("Could not move file to trash: {:?}", err);
warn!("Falling back to permanent delete");
// fall through
}
}
} else {
warn!("Trash service not available, using permanent delete");
}
// Step 3: Permanent delete
warn!("Permanently deleting file: {}", id);
self.file_repository.delete_file(id).await?;
info!("File permanently deleted: {}", id);
if let Some(hash) = &content_hash {
self.decrement_dedup_ref(hash).await;
}
Ok(false) // permanently deleted
}
}
@@ -4,39 +4,258 @@ use bytes::Bytes;
use futures::Stream;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::file_ports::{FileRetrievalUseCase, OptimizedFileContent};
use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::cache_ports::{WriteBehindCachePort, ContentCachePort};
use crate::application::ports::transcode_ports::{ImageTranscodePort, OutputFormat};
use crate::common::errors::DomainError;
use tracing::{debug, info, warn};
/// Threshold below which files are served from RAM cache (10 MB).
const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024;
/// Threshold above which mmap is used instead of streaming (100 MB).
const MMAP_THRESHOLD: u64 = 100 * 1024 * 1024;
/// Servicio para operaciones de recuperación de archivos
///
/// Implements a multi-tier download strategy:
/// - Tier 0: Write-behind cache (just-uploaded files still in RAM)
/// - Tier 1: Hot cache + optional WebP transcoding (<10 MB)
/// - Tier 2: Memory-mapped I/O (10–100 MB)
/// - Tier 3: Streaming (≥100 MB)
pub struct FileRetrievalService {
file_repository: Arc<dyn FileReadPort>,
file_read: Arc<dyn FileReadPort>,
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
content_cache: Option<Arc<dyn ContentCachePort>>,
transcode: Option<Arc<dyn ImageTranscodePort>>,
}
impl FileRetrievalService {
/// Crea un nuevo servicio de recuperación de archivos
/// Backward-compatible constructor (simple pass-through).
pub fn new(file_repository: Arc<dyn FileReadPort>) -> Self {
Self { file_repository }
Self {
file_read: file_repository,
write_behind: None,
content_cache: None,
transcode: None,
}
}
/// Full constructor with all infrastructure ports.
pub fn new_full(
file_read: Arc<dyn FileReadPort>,
write_behind: Arc<dyn WriteBehindCachePort>,
content_cache: Arc<dyn ContentCachePort>,
transcode: Arc<dyn ImageTranscodePort>,
) -> Self {
Self {
file_read,
write_behind: Some(write_behind),
content_cache: Some(content_cache),
transcode: Some(transcode),
}
}
// ── private helpers ──────────────────────────────────────────
/// Try to transcode image content to WebP and return transcoded variant.
async fn try_transcode(
&self,
id: &str,
content: &Bytes,
mime: &str,
file_size: u64,
accept_webp: bool,
) -> Option<(Bytes, String)> {
if !accept_webp {
return None;
}
let transcode = self.transcode.as_ref()?;
if !transcode.should_transcode(mime, file_size) {
return None;
}
let format = OutputFormat::WebP;
match transcode.get_transcoded(id, content, mime, format).await {
Ok((transcoded, webp_mime, true)) => {
debug!(
"🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)",
content.len(),
transcoded.len(),
(1.0 - transcoded.len() as f64 / content.len().max(1) as f64) * 100.0
);
Some((transcoded, webp_mime))
}
_ => None,
}
}
}
#[async_trait]
impl FileRetrievalUseCase for FileRetrievalService {
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError> {
let file = self.file_repository.get_file(id).await?;
let file = self.file_read.get_file(id).await?;
Ok(FileDto::from(file))
}
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
// Normalize the path (remove leading/trailing slashes)
let path = path.trim_start_matches('/').trim_end_matches('/');
// List all files and find the one with matching path
let all_files = self.list_files(None).await?;
for file in all_files {
let file_path = file.path.trim_start_matches('/').trim_end_matches('/');
if file_path == path
|| file_path.ends_with(&format!("/{}", path))
|| path.ends_with(&format!("/{}", file_path))
{
return Ok(file);
}
}
Err(DomainError::not_found("File", format!("not found at path: {}", path)))
}
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
let files = self.file_repository.list_files(folder_id).await?;
let files = self.file_read.list_files(folder_id).await?;
Ok(files.into_iter().map(FileDto::from).collect())
}
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
self.file_repository.get_file_content(id).await
self.file_read.get_file_content(id).await
}
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
self.file_repository.get_file_stream(id).await
async fn get_file_stream(
&self,
id: &str,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
self.file_read.get_file_stream(id).await
}
/// Multi-tier optimized download.
async fn get_file_optimized(
&self,
id: &str,
accept_webp: bool,
prefer_original: bool,
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
let file = self.file_read.get_file(id).await?;
let dto = FileDto::from(file);
let mime_type = dto.mime_type.clone();
let file_size = dto.size;
let file_name = dto.name.clone();
let modified_at = dto.modified_at.clone();
let do_transcode = accept_webp && !prefer_original;
// ── Tier 0: Write-behind cache ───────────────────────
if let Some(wb) = &self.write_behind {
if let Some(pending) = wb.get_pending(id).await {
debug!("⚡ TIER 0 Write-Behind HIT: {} ({} bytes)", file_name, pending.len());
let (data, mime) = if do_transcode {
if let Some((t, m)) = self.try_transcode(id, &pending, &mime_type, file_size, true).await {
(t, m)
} else {
(pending, mime_type.clone())
}
} else {
(pending, mime_type.clone())
};
return Ok((dto, OptimizedFileContent::Bytes {
data,
mime_type: mime,
was_transcoded: do_transcode,
}));
}
}
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
if file_size < CACHE_THRESHOLD {
// Check content cache first
if let Some(cache) = &self.content_cache {
if let Some((cached, _etag, _ct)) = cache.get(id).await {
debug!("🔥 TIER 1 Cache HIT: {} ({} bytes)", file_name, cached.len());
if do_transcode {
if let Some((t, m)) = self.try_transcode(id, &cached, &mime_type, file_size, true).await {
return Ok((dto, OptimizedFileContent::Bytes {
data: t,
mime_type: m,
was_transcoded: true,
}));
}
}
return Ok((dto, OptimizedFileContent::Bytes {
data: cached,
mime_type: mime_type.clone(),
was_transcoded: false,
}));
}
}
// Cache miss – load from disk
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", file_name);
let content = self.file_read.get_file_content(id).await?;
let content_bytes = Bytes::from(content);
// Store in cache
if let Some(cache) = &self.content_cache {
let etag = format!("\"{}-{}\"", id, modified_at);
cache.put(id.to_string(), content_bytes.clone(), etag, mime_type.clone()).await;
}
if do_transcode {
if let Some((t, m)) = self.try_transcode(id, &content_bytes, &mime_type, file_size, true).await {
return Ok((dto, OptimizedFileContent::Bytes {
data: t,
mime_type: m,
was_transcoded: true,
}));
}
}
return Ok((dto, OptimizedFileContent::Bytes {
data: content_bytes,
mime_type: mime_type.clone(),
was_transcoded: false,
}));
}
// ── Tier 2: MMAP (10–100 MB) ────────────────────────
if file_size < MMAP_THRESHOLD {
info!("🗺️ TIER 2 MMAP: {} ({} MB)", file_name, file_size / (1024 * 1024));
match self.file_read.get_file_mmap(id).await {
Ok(mmap_content) => {
return Ok((dto, OptimizedFileContent::Mmap(mmap_content)));
}
Err(e) => {
warn!("MMAP failed, falling back to streaming: {}", e);
// fall through to streaming
}
}
}
// ── Tier 3: Streaming (≥100 MB) ─────────────────────
info!("📡 TIER 3 STREAMING: {} ({} MB)", file_name, file_size / (1024 * 1024));
match self.file_read.get_file_stream(id).await {
Ok(stream) => Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream)))),
Err(e) => {
warn!("Streaming failed, last-resort content load: {}", e);
let content = self.file_read.get_file_content(id).await?;
Ok((dto, OptimizedFileContent::Bytes {
data: Bytes::from(content),
mime_type: mime_type.clone(),
was_transcoded: false,
}))
}
}
}
/// Range-based streaming for HTTP Range Requests.
async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>,
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
self.file_read.get_file_range_stream(id, start, end).await
}
}
-437
View File
@@ -1,437 +0,0 @@
use std::sync::Arc;
use thiserror::Error;
use async_trait::async_trait;
use crate::domain::repositories::file_repository::FileRepositoryError;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::inbound::FileUseCase;
use crate::application::ports::outbound::FileStoragePort;
use crate::common::errors::DomainError;
use futures::Stream;
use bytes::Bytes;
/**
* File service-specific error types.
*
* This enum represents the application-level errors that can occur during file operations,
* providing a translation layer between domain/infrastructure errors and application errors.
*/
#[derive(Debug, Error)]
pub enum FileServiceError {
/// Returned when a requested file cannot be found
#[error("File not found: {0}")]
NotFound(String),
/// Returned when a file operation conflicts with existing files
#[error("File already exists: {0}")]
Conflict(String),
/// Returned when file access fails due to permissions or I/O issues
#[error("File access error: {0}")]
AccessError(String),
/// Returned when a file path is invalid
#[error("Invalid file path: {0}")]
InvalidPath(String),
/// Generic internal error for unexpected failures
#[error("Internal error: {0}")]
InternalError(String),
}
/**
* Converts repository errors to service errors.
*
* This implementation maps low-level repository errors to more
* application-appropriate error types, abstracting away the implementation details.
*/
impl From<FileRepositoryError> for FileServiceError {
fn from(err: FileRepositoryError) -> Self {
match err {
FileRepositoryError::NotFound(id) => FileServiceError::NotFound(id),
FileRepositoryError::AlreadyExists(path) => FileServiceError::Conflict(path),
FileRepositoryError::InvalidPath(path) => FileServiceError::InvalidPath(path),
FileRepositoryError::IoError(e) => FileServiceError::AccessError(e.to_string()),
FileRepositoryError::Timeout(msg) => FileServiceError::AccessError(format!("Operation timed out: {}", msg)),
_ => FileServiceError::InternalError(err.to_string()),
}
}
}
/**
* Converts domain errors to service errors.
*
* This implementation ensures that general domain errors are properly translated
* to file service-specific errors while preserving their semantic meaning.
*/
impl From<DomainError> for FileServiceError {
fn from(err: DomainError) -> Self {
match err.kind {
crate::common::errors::ErrorKind::NotFound => FileServiceError::NotFound(err.to_string()),
crate::common::errors::ErrorKind::AlreadyExists => FileServiceError::Conflict(err.to_string()),
crate::common::errors::ErrorKind::InvalidInput => FileServiceError::InvalidPath(err.to_string()),
crate::common::errors::ErrorKind::AccessDenied => FileServiceError::AccessError(err.to_string()),
_ => FileServiceError::InternalError(err.to_string()),
}
}
}
/**
* Converts service errors to domain errors.
*
* This implementation allows service errors to be propagated up the call stack as
* domain errors when crossing architectural boundaries.
*/
impl From<FileServiceError> for DomainError {
fn from(err: FileServiceError) -> Self {
match err {
FileServiceError::NotFound(id) => DomainError::not_found("File", id),
FileServiceError::Conflict(path) => DomainError::already_exists("File", path),
FileServiceError::InvalidPath(path) => DomainError::validation_error(format!("Invalid path: {}", path)),
FileServiceError::AccessError(msg) => DomainError::access_denied("File", msg),
FileServiceError::InternalError(msg) => DomainError::internal_error("File", msg),
}
}
}
/**
* Type alias for results of file service operations.
*
* Provides a convenient way to return either a successful value or a FileServiceError.
*/
pub type FileServiceResult<T> = Result<T, FileServiceError>;
/**
* Service component for file operations in the application layer.
*
* The FileService implements the application use cases related to files by orchestrating
* domain logic and infrastructure components. It acts as an adapter between the inbound
* ports (interfaces) and outbound ports (repositories), translating between DTOs and
* domain entities.
*/
pub struct FileService {
/// Repository responsible for file storage operations
file_repository: Arc<dyn FileStoragePort>,
}
impl FileService {
/// Creates a new file service
pub fn new(file_repository: Arc<dyn FileStoragePort>) -> Self {
Self { file_repository }
}
/// Creates a stub implementation for testing and middleware
pub fn new_stub() -> impl FileUseCase {
struct FileServiceStub;
#[async_trait]
impl FileUseCase for FileServiceStub {
async fn upload_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn get_file(&self, _id: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn get_file_by_path(&self, _path: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn create_file(&self, _parent_path: &str, _filename: &str, _content: &[u8], _content_type: &str) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
async fn update_file(&self, _path: &str, _content: &[u8]) -> Result<(), DomainError> {
Ok(())
}
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
Ok(vec![])
}
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
Ok(())
}
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_file_stream(&self, _id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
let empty_stream = futures::stream::empty();
Ok(Box::new(empty_stream))
}
async fn move_file(&self, _file_id: &str, _folder_id: Option<String>) -> Result<FileDto, DomainError> {
Ok(FileDto::empty())
}
}
FileServiceStub
}
/// Uploads a new file from bytes
pub async fn upload_file_from_bytes(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> FileServiceResult<FileDto>
{
let file = self.file_repository.save_file(name, folder_id, content_type, content).await
.map_err(FileServiceError::from)?;
Ok(FileDto::from(file))
}
/// Uploads a file using streaming - writes directly to disk without memory accumulation
///
/// This is the preferred method for large file uploads:
/// - Constant memory usage (~10MB) regardless of file size
/// - Better handling of slow connections
/// - Atomic writes with crash safety
pub async fn upload_file_from_stream(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
stream: std::pin::Pin<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>>,
) -> FileServiceResult<FileDto>
{
let file = self.file_repository.save_file_from_stream(name, folder_id, content_type, stream).await
.map_err(FileServiceError::from)?;
Ok(FileDto::from(file))
}
/// Registers a file for write-behind upload (ZERO LATENCY)
///
/// This method:
/// 1. Creates file metadata and ID (~0.1ms)
/// 2. Returns immediately WITHOUT writing content to disk
/// 3. Caller must use WriteBehindCache to write content asynchronously
///
/// Returns: (FileDto, PathBuf) where PathBuf is where content should be written
pub async fn register_file_deferred(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
size: u64,
) -> FileServiceResult<(FileDto, std::path::PathBuf)>
{
let (file, path) = self.file_repository.register_file_deferred(name, folder_id, content_type, size).await
.map_err(FileServiceError::from)?;
Ok((FileDto::from(file), path))
}
/// Gets a file by ID
pub async fn get_file(&self, id: &str) -> FileServiceResult<FileDto> {
let file = self.file_repository.get_file(id).await
.map_err(FileServiceError::from)?;
Ok(FileDto::from(file))
}
/// Gets a file by path (needed for WebDAV)
pub async fn get_file_by_path(&self, path: &str) -> FileServiceResult<FileDto> {
// This is a simple implementation for WebDAV support
// First, normalize the path (remove leading/trailing slashes)
let path = path.trim_start_matches('/').trim_end_matches('/');
// List all files and find the one with matching path
let all_files = self.list_files(None).await?;
for file in all_files {
let file_path = file.path.trim_start_matches('/').trim_end_matches('/');
if file_path == path || file_path.ends_with(&format!("/{}", path)) || path.ends_with(&format!("/{}", file_path)) {
return Ok(file);
}
}
// If no file found, return an error
Err(FileServiceError::NotFound(format!("File not found at path: {}", path)))
}
/// Creates or updates a file at a specific path (needed for WebDAV)
pub async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> FileServiceResult<FileDto> {
// Get parent folder ID if parent path is not empty
let parent_id = if !parent_path.is_empty() {
match self.file_repository.get_parent_folder_id(parent_path).await {
Ok(id) => Some(id),
Err(_) => None // If parent doesn't exist, use root
}
} else {
None // Root folder
};
// Save the file with the provided filename and parent folder
let file = self.file_repository.save_file(
filename.to_string(),
parent_id,
content_type.to_string(),
content.to_vec()
).await.map_err(FileServiceError::from)?;
Ok(FileDto::from(file))
}
/// Updates an existing file (needed for WebDAV)
pub async fn update_file(&self, path: &str, content: &[u8]) -> FileServiceResult<()> {
// First, try to get the file by path
match self.get_file_by_path(path).await {
Ok(file) => {
// Update the file content
self.file_repository.update_file_content(&file.id, content.to_vec())
.await
.map_err(FileServiceError::from)
},
Err(_) => {
// If file doesn't exist, extract filename and parent path and create it
let path = path.trim_start_matches('/').trim_end_matches('/');
let (parent_path, filename) = if let Some(idx) = path.rfind('/') {
(&path[..idx], &path[idx+1..])
} else {
("", path)
};
// Create new file
self.create_file(parent_path, filename, content, "application/octet-stream").await?;
Ok(())
}
}
}
/// Lists files in a folder
pub async fn list_files(&self, folder_id: Option<&str>) -> FileServiceResult<Vec<FileDto>> {
let files = self.file_repository.list_files(folder_id).await
.map_err(FileServiceError::from)?;
Ok(files.into_iter().map(FileDto::from).collect())
}
/// Deletes a file
pub async fn delete_file(&self, id: &str) -> FileServiceResult<()> {
self.file_repository.delete_file(id).await
.map_err(FileServiceError::from)
}
/// Gets file content as bytes - use for small files only
pub async fn get_file_content(&self, id: &str) -> FileServiceResult<Vec<u8>> {
self.file_repository.get_file_content(id).await
.map_err(FileServiceError::from)
}
/// Gets file content as stream - better for large files
pub async fn get_file_stream(&self, id: &str) -> FileServiceResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
self.file_repository.get_file_stream(id).await
.map_err(FileServiceError::from)
}
/// Gets a range of file content as stream - for HTTP Range Requests
///
/// Supports:
/// - Video seeking
/// - Resumable downloads
/// - Parallel chunk downloads
pub async fn get_file_range_stream(
&self,
id: &str,
start: u64,
end: Option<u64>
) -> FileServiceResult<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> {
self.file_repository.get_file_range_stream(id, start, end).await
.map_err(FileServiceError::from)
}
/// Memory-maps a file for zero-copy access (optimal for 10-100MB files)
///
/// Uses kernel mmap for files where:
/// - Full RAM cache would be wasteful
/// - Streaming adds unnecessary chunking overhead
pub async fn get_file_mmap(&self, id: &str) -> FileServiceResult<Bytes> {
self.file_repository.get_file_mmap(id).await
.map_err(FileServiceError::from)
}
/// Moves a file to a new folder using filesystem operations directly
pub async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> FileServiceResult<FileDto> {
tracing::info!("Moving file with ID: {} to folder: {:?}", file_id, folder_id);
// Use the efficient repository implementation that uses rename
let moved_file = self.file_repository.move_file(file_id, folder_id).await
.map_err(|e| {
tracing::error!("Error moving file (ID: {}): {}", file_id, e);
FileServiceError::from(e)
})?;
tracing::info!("File moved successfully: {} (ID: {}) to folder: {:?}",
moved_file.name(), moved_file.id(), moved_file.folder_id());
Ok(FileDto::from(moved_file))
}
}
#[async_trait]
impl FileUseCase for FileService {
async fn upload_file(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
content: Vec<u8>,
) -> Result<FileDto, DomainError> {
FileService::upload_file_from_bytes(self, name, folder_id, content_type, content).await
.map_err(DomainError::from)
}
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError> {
FileService::get_file(self, id).await
.map_err(DomainError::from)
}
async fn get_file_by_path(&self, path: &str) -> Result<FileDto, DomainError> {
FileService::get_file_by_path(self, path).await
.map_err(DomainError::from)
}
async fn create_file(&self, parent_path: &str, filename: &str, content: &[u8], content_type: &str) -> Result<FileDto, DomainError> {
FileService::create_file(self, parent_path, filename, content, content_type).await
.map_err(DomainError::from)
}
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> {
FileService::update_file(self, path, content).await
.map_err(DomainError::from)
}
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
FileService::list_files(self, folder_id).await
.map_err(DomainError::from)
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
FileService::delete_file(self, id).await
.map_err(DomainError::from)
}
async fn get_file_content(&self, id: &str) -> Result<Vec<u8>, DomainError> {
FileService::get_file_content(self, id).await
.map_err(DomainError::from)
}
async fn get_file_stream(&self, id: &str) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
FileService::get_file_stream(self, id).await
.map_err(DomainError::from)
}
async fn move_file(&self, file_id: &str, folder_id: Option<String>) -> Result<FileDto, DomainError> {
FileService::move_file(self, file_id, folder_id).await
.map_err(DomainError::from)
}
}
+265 -51
View File
@@ -1,57 +1,136 @@
use std::sync::Arc;
use std::pin::Pin;
use async_trait::async_trait;
use bytes::Bytes;
use futures::Stream;
use crate::application::dtos::file_dto::FileDto;
use crate::application::ports::file_ports::FileUploadUseCase;
use crate::application::ports::storage_ports::FileWritePort;
use crate::application::ports::file_ports::{FileUploadUseCase, UploadStrategy};
use crate::application::ports::storage_ports::{FileWritePort, FileReadPort};
use crate::application::ports::cache_ports::WriteBehindCachePort;
use crate::application::ports::dedup_ports::DedupPort;
use crate::common::errors::DomainError;
use crate::application::ports::storage_ports::StorageUsagePort;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
/// Threshold for using streaming upload (files >= 1MB use streaming)
const STREAMING_UPLOAD_THRESHOLD: usize = 1 * 1024 * 1024;
/// Threshold for write-behind cache (files < 256KB get instant response)
const WRITE_BEHIND_THRESHOLD: usize = 256 * 1024;
/// Helper function to extract username from folder path string
fn extract_username_from_path(path: &str) -> Option<String> {
// Check if path contains the folder pattern
if !path.contains("Mi Carpeta - ") {
return None;
}
// Split by the pattern and get the second part
let parts: Vec<&str> = path.split("Mi Carpeta - ").collect();
if parts.len() <= 1 {
return None;
}
// Trim and return as owned String
Some(parts[1].trim().to_string())
}
/// Servicio para operaciones de subida de archivos
///
/// Encapsulates the three-tier upload strategy:
/// 1. **Write-Behind** (<256 KB): store in RAM, respond instantly, flush async.
/// 2. **Buffered** (256 KB – 1 MB): collect bytes, write, respond.
/// 3. **Streaming** (≥1 MB): pipe chunks to disk with constant memory.
///
/// Also runs deduplication so duplicate content is never stored twice.
pub struct FileUploadService {
file_repository: Arc<dyn FileWritePort>,
storage_usage_service: Option<Arc<dyn StorageUsagePort>>,
/// Write port — handles save, streaming, deferred registration
file_write: Arc<dyn FileWritePort>,
/// Read port — needed for WebDAV create_file / update_file
file_read: Option<Arc<dyn FileReadPort>>,
/// Optional write-behind cache for instant uploads
write_behind: Option<Arc<dyn WriteBehindCachePort>>,
/// Optional dedup service for content-addressable storage
dedup: Option<Arc<dyn DedupPort>>,
/// Optional storage usage tracking
storage_usage_service: Option<Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>>,
}
impl FileUploadService {
/// Crea un nuevo servicio de subida de archivos
/// Backward-compatible constructor (no write-behind, no dedup).
pub fn new(file_repository: Arc<dyn FileWritePort>) -> Self {
Self {
file_repository,
Self {
file_write: file_repository,
file_read: None,
write_behind: None,
dedup: None,
storage_usage_service: None,
}
}
/// Full constructor with all infrastructure ports.
pub fn new_full(
file_write: Arc<dyn FileWritePort>,
file_read: Arc<dyn FileReadPort>,
write_behind: Arc<dyn WriteBehindCachePort>,
dedup: Arc<dyn DedupPort>,
) -> Self {
Self {
file_write,
file_read: Some(file_read),
write_behind: Some(write_behind),
dedup: Some(dedup),
storage_usage_service: None,
}
}
/// Configura el servicio de uso de almacenamiento
pub fn with_storage_usage_service(
mut self,
storage_usage_service: Arc<dyn StorageUsagePort>
mut self,
storage_usage_service: Arc<dyn crate::application::ports::storage_ports::StorageUsagePort>,
) -> Self {
self.storage_usage_service = Some(storage_usage_service);
self
}
// ── private helpers ──────────────────────────────────────────
/// Run dedup tracking (non-fatal on failure).
async fn run_dedup(&self, data: &[u8], content_type: &str) {
let Some(dedup) = &self.dedup else { return };
match dedup.store_bytes(data, Some(content_type.to_string())).await {
Ok(result) => {
if result.was_deduplicated() {
info!(
"🔗 DEDUP: content already exists (hash: {}, saved {} bytes)",
&result.hash()[..12],
result.size()
);
} else {
info!("💾 DEDUP: new content stored (hash: {})", &result.hash()[..12]);
}
}
Err(e) => {
warn!("⚠️ DEDUP: Failed to store in blob store: {}", e);
}
}
}
/// Optionally update storage usage after a successful upload.
fn maybe_update_storage_usage(&self, file: &FileDto) {
if let Some(storage_service) = &self.storage_usage_service {
// Extract username from the file's own path (contains folder structure)
let file_path = file.path.clone();
if let Some(username) = extract_username_from_path(&file_path) {
let service_clone = Arc::clone(storage_service);
tokio::spawn(async move {
match service_clone.update_user_storage_usage(&username).await {
Ok(usage) => debug!("Updated storage usage for user {} to {} bytes", username, usage),
Err(e) => warn!("Failed to update storage usage for {}: {}", username, e),
}
});
}
}
}
}
#[async_trait]
impl FileUploadUseCase for FileUploadService {
/// Simple byte-based upload (backward compatible).
async fn upload_file(
&self,
name: String,
@@ -59,43 +138,178 @@ impl FileUploadUseCase for FileUploadService {
content_type: String,
content: Vec<u8>,
) -> Result<FileDto, DomainError> {
// Upload the file
let file = self.file_repository.save_file(name, folder_id, content_type, content).await?;
// Extract the owner's user ID if available
// We could make this more explicit by adding a user_id parameter
if let Some(storage_service) = &self.storage_usage_service {
// Extract user ID from folder pattern 'Mi Carpeta - {username}'
if let Some(folder_id) = file.folder_id() {
// Since we don't have direct access to folder details,
// we'll use pattern matching on the folder ID
// In a more complete implementation, we would use a folder repository
let folder_id_str = folder_id;
// Check if we can extract a username from context
if let Ok(folder_path) = self.file_repository.get_folder_path_str(folder_id_str).await {
// Process the string to extract username without creating borrowing issues
if let Some(username) = extract_username_from_path(&folder_path) {
// Find user by username and update their storage usage
// We do this asynchronously to avoid blocking the upload response
let service_clone = Arc::clone(storage_service);
tokio::spawn(async move {
match service_clone.update_user_storage_usage(&username).await {
Ok(usage) => {
debug!("Updated storage usage for user {} to {} bytes", username, usage);
},
Err(e) => {
warn!("Failed to update storage usage for {}: {}", username, e);
}
}
});
let file = self.file_write.save_file(name, folder_id, content_type, content).await?;
let dto = FileDto::from(file);
self.maybe_update_storage_usage(&dto);
Ok(dto)
}
/// Smart three-tier upload with write-behind cache and dedup.
async fn smart_upload(
&self,
name: String,
folder_id: Option<String>,
content_type: String,
chunks: Vec<Bytes>,
total_size: usize,
) -> Result<(FileDto, UploadStrategy), DomainError> {
use futures::stream;
// ─── Dedup (runs for all tiers) ──────────────────────
{
let dedup_data: Vec<u8> = {
let mut combined = Vec::with_capacity(total_size);
for chunk in &chunks {
combined.extend_from_slice(chunk);
}
combined
};
self.run_dedup(&dedup_data, &content_type).await;
}
// ─── TIER 1: Write-Behind (<256 KB) ──────────────────
if total_size < WRITE_BEHIND_THRESHOLD {
if let Some(wb) = &self.write_behind {
if wb.is_eligible_size(total_size) {
let data: Bytes = if chunks.len() == 1 {
chunks.into_iter().next().unwrap()
} else {
let mut combined = Vec::with_capacity(total_size);
for chunk in chunks {
combined.extend_from_slice(&chunk);
}
combined.into()
};
let (file, target_path) = self
.file_write
.register_file_deferred(name.clone(), folder_id, content_type, total_size as u64)
.await?;
let dto = FileDto::from(file);
if let Err(e) = wb.put_pending(dto.id.clone(), data, target_path).await {
return Err(DomainError::internal_error("file", format!(
"Write-behind cache failed: {}",
e
)));
}
} else {
warn!("Could not get folder path for ID: {}", folder_id_str);
info!("⚡ WRITE-BEHIND UPLOAD: {} (ID: {}, ~0ms latency)", name, dto.id);
self.maybe_update_storage_usage(&dto);
return Ok((dto, UploadStrategy::WriteBehind));
}
}
}
Ok(FileDto::from(file))
// ─── TIER 2: Streaming (≥1 MB) ──────────────────────
if total_size >= STREAMING_UPLOAD_THRESHOLD {
let chunk_stream = stream::iter(chunks.into_iter().map(|c| Ok::<_, std::io::Error>(c)));
let pinned_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
Box::pin(chunk_stream);
let file = self
.file_write
.save_file_from_stream(name.clone(), folder_id, content_type, pinned_stream)
.await?;
let dto = FileDto::from(file);
info!(
"✅ STREAMING UPLOAD: {} ({} MB, ID: {})",
name,
total_size / (1024 * 1024),
dto.id
);
self.maybe_update_storage_usage(&dto);
return Ok((dto, UploadStrategy::Streaming));
}
// ─── TIER 3: Buffered (256 KB – 1 MB) ───────────────
let data = if chunks.len() == 1 {
chunks.into_iter().next().unwrap().to_vec()
} else {
let mut combined = Vec::with_capacity(total_size);
for chunk in chunks {
combined.extend_from_slice(&chunk);
}
combined
};
let file = self
.file_write
.save_file(name.clone(), folder_id, content_type, data)
.await?;
let dto = FileDto::from(file);
info!("✅ BUFFERED UPLOAD: {} (ID: {})", name, dto.id);
self.maybe_update_storage_usage(&dto);
Ok((dto, UploadStrategy::Buffered))
}
/// Creates a file at a specific path (for WebDAV PUT on new resource).
async fn create_file(
&self,
parent_path: &str,
filename: &str,
content: &[u8],
content_type: &str,
) -> Result<FileDto, DomainError> {
// Resolve parent folder ID from path
let parent_id = if !parent_path.is_empty() {
if let Some(file_read) = &self.file_read {
match file_read.get_parent_folder_id(parent_path).await {
Ok(id) => Some(id),
Err(_) => None, // If parent doesn't exist, use root
}
} else {
None
}
} else {
None
};
let file = self
.file_write
.save_file(
filename.to_string(),
parent_id,
content_type.to_string(),
content.to_vec(),
)
.await?;
let dto = FileDto::from(file);
self.maybe_update_storage_usage(&dto);
Ok(dto)
}
/// Updates an existing file's content, or creates it if not found (for WebDAV PUT).
async fn update_file(&self, path: &str, content: &[u8]) -> Result<(), DomainError> {
let path_normalized = path.trim_start_matches('/').trim_end_matches('/');
// Try to find the existing file by path
if let Some(file_read) = &self.file_read {
let all_files = file_read.list_files(None).await?;
for file in &all_files {
let dto = FileDto::from(file.clone());
let dto_path = dto.path.trim_start_matches('/').trim_end_matches('/');
if dto_path == path_normalized
|| dto_path.ends_with(&format!("/{}", path_normalized))
|| path_normalized.ends_with(&format!("/{}", dto_path))
{
// Found it — update in place
self.file_write
.update_file_content(file.id(), content.to_vec())
.await?;
return Ok(());
}
}
}
// File not found — create it
let (parent_path, filename) = if let Some(idx) = path_normalized.rfind('/') {
(&path_normalized[..idx], &path_normalized[idx + 1..])
} else {
("", path_normalized)
};
self.create_file(parent_path, filename, content, "application/octet-stream")
.await?;
Ok(())
}
}
-1
View File
@@ -5,7 +5,6 @@ pub mod contact_service;
pub mod favorites_service;
pub mod file_management_service;
pub mod file_retrieval_service;
pub mod file_service;
pub mod file_upload_service;
pub mod file_use_case_factory;
pub mod folder_service;
+28 -184
View File
@@ -1,24 +1,25 @@
use std::sync::Arc;
use async_trait::async_trait;
use sqlx::{PgPool, Row};
use tracing::{info, error};
use uuid::Uuid;
use tracing::info;
use crate::common::errors::{Result, DomainError, ErrorKind};
use crate::application::ports::recent_ports::RecentItemsUseCase;
use crate::application::ports::recent_ports::{RecentItemsUseCase, RecentItemsRepositoryPort};
use crate::application::dtos::recent_dto::RecentItemDto;
/// Implementación del caso de uso para gestionar elementos recientes
/// Implementación del caso de uso para gestionar elementos recientes.
///
/// Depende de `RecentItemsRepositoryPort` (outbound port) en lugar
/// de acceder directamente a `PgPool`, siguiendo la arquitectura hexagonal.
pub struct RecentService {
db_pool: Arc<PgPool>,
max_recent_items: i32, // Número máximo de elementos recientes a mantener por usuario
repo: Arc<dyn RecentItemsRepositoryPort>,
max_recent_items: i32,
}
impl RecentService {
/// Crear un nuevo servicio de elementos recientes
pub fn new(db_pool: Arc<PgPool>, max_recent_items: i32) -> Self {
Self {
db_pool,
max_recent_items: max_recent_items.max(1).min(100), // Entre 1 y 100
pub fn new(repo: Arc<dyn RecentItemsRepositoryPort>, max_recent_items: i32) -> Self {
Self {
repo,
max_recent_items: max_recent_items.max(1).min(100),
}
}
}
@@ -28,205 +29,48 @@ impl RecentItemsUseCase for RecentService {
/// Obtener elementos recientes de un usuario
async fn get_recent_items(&self, user_id: &str, limit: Option<i32>) -> Result<Vec<RecentItemDto>> {
info!("Obteniendo elementos recientes para usuario: {}", user_id);
// Convertir user_id a UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Determinar límite (usar el especificado o el máximo del servicio)
let limit_value = limit.unwrap_or(self.max_recent_items).min(self.max_recent_items);
// Ejecutar consulta SQL
let rows = sqlx::query(
r#"
SELECT
id::TEXT as "id",
user_id::TEXT as "user_id",
item_id as "item_id",
item_type as "item_type",
accessed_at as "accessed_at"
FROM auth.user_recent_files
WHERE user_id = $1::TEXT
ORDER BY accessed_at DESC
LIMIT $2
"#
)
.bind(user_uuid)
.bind(limit_value)
.fetch_all(&*self.db_pool)
.await
.map_err(|e| {
error!("Error de base de datos al obtener elementos recientes: {}", e);
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Fallo al obtener elementos recientes: {}", e)
)
})?;
// Convertir filas a DTOs
let mut recent_items = Vec::with_capacity(rows.len());
for row in rows {
recent_items.push(RecentItemDto {
id: row.get("id"),
user_id: row.get("user_id"),
item_id: row.get("item_id"),
item_type: row.get("item_type"),
accessed_at: row.get("accessed_at"),
});
}
info!("Recuperados {} elementos recientes para usuario {}", recent_items.len(), user_id);
Ok(recent_items)
let items = self.repo.get_recent_items(user_id, limit_value).await?;
info!("Recuperados {} elementos recientes para usuario {}", items.len(), user_id);
Ok(items)
}
/// Registrar acceso a un elemento
async fn record_item_access(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<()> {
info!("Registrando acceso a {} '{}' para usuario {}", item_type, item_id, user_id);
// Validar tipo de elemento
if item_type != "file" && item_type != "folder" {
return Err(DomainError::new(
ErrorKind::InvalidInput,
"RecentItems",
"El tipo de elemento debe ser 'file' o 'folder'"
"El tipo de elemento debe ser 'file' o 'folder'",
));
}
// Convertir user_id a UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Ejecutar consulta SQL con UPSERT para mantener un único registro por elemento
sqlx::query(
r#"
INSERT INTO auth.user_recent_files (user_id, item_id, item_type, accessed_at)
VALUES ($1::TEXT, $2, $3, CURRENT_TIMESTAMP)
ON CONFLICT (user_id, item_id, item_type)
DO UPDATE SET accessed_at = CURRENT_TIMESTAMP
"#
)
.bind(user_uuid)
.bind(item_id)
.bind(item_type)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Error de base de datos al registrar acceso a elemento: {}", e);
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Fallo al registrar acceso a elemento: {}", e)
)
})?;
// Eliminar elementos antiguos que excedan el límite
self.prune_old_items(user_id).await?;
self.repo.upsert_access(user_id, item_id, item_type).await?;
self.repo.prune(user_id, self.max_recent_items).await?;
info!("Registrado correctamente acceso a {} '{}' para usuario {}", item_type, item_id, user_id);
Ok(())
}
/// Eliminar un elemento de recientes
async fn remove_from_recent(&self, user_id: &str, item_id: &str, item_type: &str) -> Result<bool> {
info!("Eliminando {} '{}' de recientes para usuario {}", item_type, item_id, user_id);
// Convertir user_id a UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Ejecutar consulta SQL
let result = sqlx::query(
r#"
DELETE FROM auth.user_recent_files
WHERE user_id = $1::TEXT AND item_id = $2 AND item_type = $3
"#
)
.bind(user_uuid)
.bind(item_id)
.bind(item_type)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Error de base de datos al eliminar elemento de recientes: {}", e);
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Fallo al eliminar de recientes: {}", e)
)
})?;
let removed = result.rows_affected() > 0;
let removed = self.repo.remove_item(user_id, item_id, item_type).await?;
info!(
"{} {} '{}' de recientes para usuario {}",
"{} {} '{}' de recientes para usuario {}",
if removed { "Eliminado correctamente" } else { "No se encontró" },
item_type,
item_id,
user_id
item_type, item_id, user_id
);
Ok(removed)
}
/// Limpiar todos los elementos recientes
async fn clear_recent_items(&self, user_id: &str) -> Result<()> {
info!("Limpiando todos los elementos recientes para usuario {}", user_id);
// Convertir user_id a UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Ejecutar consulta SQL
sqlx::query(
r#"
DELETE FROM auth.user_recent_files
WHERE user_id = $1::TEXT
"#
)
.bind(user_uuid)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Error de base de datos al limpiar elementos recientes: {}", e);
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Fallo al limpiar elementos recientes: {}", e)
)
})?;
self.repo.clear_all(user_id).await?;
info!("Limpiados todos los elementos recientes para usuario {}", user_id);
Ok(())
}
}
impl RecentService {
/// Método auxiliar para eliminar elementos antiguos que excedan el límite
async fn prune_old_items(&self, user_id: &str) -> Result<()> {
// Convertir user_id a UUID
let user_uuid = Uuid::parse_str(user_id)?;
// Eliminar elementos antiguos que excedan el límite
sqlx::query(
r#"
DELETE FROM auth.user_recent_files
WHERE id IN (
SELECT id FROM auth.user_recent_files
WHERE user_id = $1::TEXT
ORDER BY accessed_at DESC
OFFSET $2
)
"#
)
.bind(user_uuid)
.bind(self.max_recent_items)
.execute(&*self.db_pool)
.await
.map_err(|e| {
error!("Error al podar elementos recientes antiguos: {}", e);
DomainError::new(
ErrorKind::InternalError,
"RecentItems",
format!("Fallo al limpiar elementos recientes antiguos: {}", e)
)
})?;
Ok(())
}
}
+4 -3
View File
@@ -10,7 +10,8 @@ use crate::application::dtos::search_dto::{SearchCriteriaDto, SearchResultsDto};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort};
use crate::application::ports::outbound::FolderStoragePort;
use crate::application::ports::storage_ports::FileReadPort;
/**
* Implementación del servicio de búsqueda para archivos y carpetas.
@@ -22,7 +23,7 @@ use crate::application::ports::outbound::{FileStoragePort, FolderStoragePort};
*/
pub struct SearchService {
/// Repositorio para operaciones con archivos
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
/// Repositorio para operaciones con carpetas
folder_repository: Arc<dyn FolderStoragePort>,
@@ -66,7 +67,7 @@ impl SearchService {
* @param max_cache_size Tamaño máximo de la caché
*/
pub fn new(
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
folder_repository: Arc<dyn FolderStoragePort>,
cache_ttl: u64,
max_cache_size: usize,
+130 -60
View File
@@ -10,8 +10,10 @@ use crate::{
share_dto::{CreateShareDto, ShareDto, UpdateShareDto},
},
ports::{
outbound::{FileStoragePort, FolderStoragePort},
auth_ports::PasswordHasherPort,
outbound::FolderStoragePort,
share_ports::{ShareStoragePort, ShareUseCase},
storage_ports::FileReadPort,
},
},
common::{config::AppConfig, errors::DomainError},
@@ -56,22 +58,25 @@ impl From<ShareServiceError> for DomainError {
pub struct ShareService {
config: Arc<AppConfig>,
share_repository: Arc<dyn ShareStoragePort>,
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
folder_repository: Arc<dyn FolderStoragePort>,
password_hasher: Arc<dyn PasswordHasherPort>,
}
impl ShareService {
pub fn new(
config: Arc<AppConfig>,
share_repository: Arc<dyn ShareStoragePort>,
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
folder_repository: Arc<dyn FolderStoragePort>,
password_hasher: Arc<dyn PasswordHasherPort>,
) -> Self {
Self {
config,
share_repository,
file_repository,
folder_repository,
password_hasher,
}
}
@@ -98,11 +103,19 @@ impl ShareService {
Ok(())
}
/// Hash de contraseña
/// Hash de contraseña usando Argon2id (resistente a timing attacks y GPU attacks)
fn hash_password(&self, password: &str) -> String {
// En una implementación real, usar un algoritmo seguro como bcrypt
// Para simplificar, solo devolvemos la misma contraseña
password.to_string()
use argon2::{Argon2, PasswordHasher};
use argon2::password_hash::SaltString;
use rand_core::OsRng;
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.expect("Failed to hash share password")
.to_string()
}
}
@@ -314,8 +327,13 @@ impl ShareUseCase for ShareService {
return Err(ShareServiceError::Expired.into());
}
// Verificar la contraseña
Ok(share.verify_password(password))
// Verificar la contraseña usando el port de infraestructura
match share.password_hash() {
Some(hash) => {
self.password_hasher.verify_password(password, hash)
}
None => Ok(true), // No password required
}
}
async fn register_shared_link_access(&self, token: &str) -> Result<(), DomainError> {
@@ -348,87 +366,147 @@ impl ShareUseCase for ShareService {
mod tests {
use super::*;
use crate::application::ports::share_ports::ShareStoragePort;
use crate::application::ports::auth_ports::PasswordHasherPort;
use crate::application::dtos::share_dto::SharePermissionsDto;
use crate::common::config::AppConfig;
use crate::domain::repositories::folder_repository::FolderRepository;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
struct MockPasswordHasher;
impl PasswordHasherPort for MockPasswordHasher {
fn hash_password(&self, password: &str) -> Result<String, DomainError> {
Ok(format!("hashed_{}", password))
}
fn verify_password(&self, _password: &str, _hash: &str) -> Result<bool, DomainError> {
Ok(true)
}
}
struct MockFileRepository;
struct MockFolderRepository;
#[async_trait]
impl FileStoragePort for MockFileRepository {
async fn find_file_by_id(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
impl FileReadPort for MockFileRepository {
async fn get_file(&self, id: &str) -> Result<crate::domain::entities::file::File, DomainError> {
if id == "test_file_id" {
let file = crate::domain::entities::file::File::new(
id.to_string(),
"test.txt".to_string(),
"/path/to/test.txt".to_string(),
"/test.txt".to_string(),
crate::domain::services::path_service::StoragePath::from_string("/path/to/test.txt"),
123,
"text/plain".to_string(),
None,
None,
None,
)
.unwrap();
Ok(file)
} else {
Err(DomainError::NotFound(format!("File {} not found", id)))
Err(DomainError::not_found("File", id))
}
}
// Implementación dummy para el resto de métodos requeridos
async fn find_files_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
async fn list_files(&self, _folder_id: Option<&str>) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
unimplemented!()
}
async fn save_file(&self, _file: &crate::domain::entities::file::File) -> Result<crate::domain::entities::file::File, DomainError> {
async fn get_file_content(&self, _id: &str) -> Result<Vec<u8>, DomainError> {
unimplemented!()
}
async fn delete_file(&self, _id: &str) -> Result<(), DomainError> {
async fn get_file_stream(
&self,
_id: &str,
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn find_all_files(&self) -> Result<Vec<crate::domain::entities::file::File>, DomainError> {
async fn get_file_range_stream(
&self,
_id: &str,
_start: u64,
_end: Option<u64>,
) -> Result<Box<dyn futures::Stream<Item = Result<bytes::Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn get_file_mmap(&self, _id: &str) -> Result<bytes::Bytes, DomainError> {
unimplemented!()
}
async fn get_file_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> Result<String, DomainError> {
unimplemented!()
}
}
#[async_trait]
impl FolderStoragePort for MockFolderRepository {
async fn find_folder_by_id(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
impl FolderRepository for MockFolderRepository {
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn get_folder(&self, id: &str) -> Result<crate::domain::entities::folder::Folder, DomainError> {
if id == "test_folder_id" {
let folder = crate::domain::entities::folder::Folder::new(
id.to_string(),
"test".to_string(),
"/path/to/test".to_string(),
"/test".to_string(),
None,
None,
crate::domain::services::path_service::StoragePath::from_string("/path/to/test"),
None,
)
.unwrap();
Ok(folder)
} else {
Err(DomainError::NotFound(format!("Folder {} not found", id)))
Err(DomainError::not_found("Folder", id))
}
}
// Implementación dummy para el resto de métodos requeridos
async fn find_folders_in_folder(&self, _folder_id: &str) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
async fn get_folder_by_path(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn save_folder(&self, _folder: &crate::domain::entities::folder::Folder) -> Result<crate::domain::entities::folder::Folder, DomainError> {
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
unimplemented!()
}
async fn list_folders_paginated(&self, _parent_id: Option<&str>, _offset: usize, _limit: usize, _include_total: bool) -> Result<(Vec<crate::domain::entities::folder::Folder>, Option<usize>), DomainError> {
unimplemented!()
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<crate::domain::entities::folder::Folder, DomainError> {
unimplemented!()
}
async fn delete_folder(&self, _id: &str) -> Result<(), DomainError> {
unimplemented!()
}
async fn find_all_folders(&self) -> Result<Vec<crate::domain::entities::folder::Folder>, DomainError> {
async fn folder_exists(&self, _storage_path: &crate::domain::services::path_service::StoragePath) -> Result<bool, DomainError> {
unimplemented!()
}
async fn get_folder_path(&self, _id: &str) -> Result<crate::domain::services::path_service::StoragePath, DomainError> {
unimplemented!()
}
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), DomainError> {
unimplemented!()
}
async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), DomainError> {
unimplemented!()
}
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), DomainError> {
unimplemented!()
}
}
@@ -453,8 +531,8 @@ mod tests {
let mut shares = self.shares.lock().unwrap();
let mut tokens = self.tokens.lock().unwrap();
shares.insert(share.id.clone(), share.clone());
tokens.insert(share.token.clone(), share.id.clone());
shares.insert(share.id().to_string(), share.clone());
tokens.insert(share.token().to_string(), share.id().to_string());
Ok(share.clone())
}
@@ -464,7 +542,7 @@ mod tests {
shares.get(id)
.cloned()
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
.ok_or_else(|| DomainError::not_found("Share", id))
}
async fn find_share_by_token(&self, token: &str) -> Result<Share, DomainError> {
@@ -472,11 +550,11 @@ mod tests {
let shares = self.shares.lock().unwrap();
let id = tokens.get(token)
.ok_or_else(|| DomainError::NotFound(format!("Share with token {} not found", token)))?;
.ok_or_else(|| DomainError::not_found("Share", token))?;
shares.get(id)
.cloned()
.ok_or_else(|| DomainError::NotFound(format!("Share with ID {} not found", id)))
.ok_or_else(|| DomainError::not_found("Share", id.as_str()))
}
async fn find_shares_by_item(&self, item_id: &str, item_type: &ShareItemType) -> Result<Vec<Share>, DomainError> {
@@ -484,7 +562,7 @@ mod tests {
let type_str = item_type.to_string();
let result: Vec<Share> = shares.values()
.filter(|s| s.item_id == item_id && s.item_type.to_string() == type_str)
.filter(|s| s.item_id() == item_id && s.item_type().to_string() == type_str)
.cloned()
.collect();
@@ -494,11 +572,12 @@ mod tests {
async fn update_share(&self, share: &Share) -> Result<Share, DomainError> {
let mut shares = self.shares.lock().unwrap();
if !shares.contains_key(&share.id) {
return Err(DomainError::not_found("Share", &share.id));
let id_str = share.id().to_string();
if !shares.contains_key(&id_str) {
return Err(DomainError::not_found("Share", &id_str));
}
shares.insert(share.id.clone(), share.clone());
shares.insert(id_str, share.clone());
Ok(share.clone())
}
@@ -512,7 +591,7 @@ mod tests {
.ok_or_else(|| DomainError::not_found("Share", id))?;
// Remove token mapping
tokens.remove(&share.token);
tokens.remove(share.token());
// Remove the share
shares.remove(id);
@@ -524,7 +603,7 @@ mod tests {
let shares = self.shares.lock().unwrap();
let user_shares: Vec<Share> = shares.values()
.filter(|s| s.created_by == user_id)
.filter(|s| s.created_by() == user_id)
.cloned()
.collect();
@@ -542,23 +621,14 @@ mod tests {
#[tokio::test]
async fn test_create_shared_link() {
let config = Arc::new(Config {
base_url: "http://localhost:8085".to_string(),
storage_path: "/tmp/storage".to_string(),
log_level: "info".to_string(),
port: 8085,
database_url: "".to_string(),
jwt_secret: "test_secret".to_string(),
jwt_expiration: 3600,
enable_cors: false,
cors_origins: vec![],
});
let config = Arc::new(AppConfig::default());
let share_repo = Arc::new(MockShareRepository::new());
let file_repo = Arc::new(MockFileRepository);
let folder_repo = Arc::new(MockFolderRepository);
let password_hasher = Arc::new(MockPasswordHasher);
let service = ShareService::new(config, share_repo, file_repo, folder_repo);
let service = ShareService::new(config, share_repo, file_repo, folder_repo, password_hasher);
// Test creating a file share
let dto = CreateShareDto {
@@ -580,6 +650,6 @@ mod tests {
assert_eq!(share_dto.item_id, "test_file_id");
assert_eq!(share_dto.item_type, "file");
assert!(share_dto.has_password);
assert!(share_dto.url.starts_with("http://localhost:8085/s/"));
assert!(share_dto.url.starts_with("http://127.0.0.1:8085/s/"));
}
}
+7 -127
View File
@@ -1,14 +1,11 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::RwLock;
use async_trait::async_trait;
use thiserror::Error;
use crate::domain::entities::folder::Folder;
use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryError};
use crate::domain::repositories::file_repository::FileRepositoryError;
use crate::domain::services::path_service::StoragePath;
use crate::application::ports::outbound::{IdMappingPort, StoragePort};
use crate::application::ports::outbound::{IdMappingPort, StoragePort, FolderStoragePort};
/// Errores específicos del mediador de almacenamiento
#[derive(Debug, Error)]
@@ -32,30 +29,6 @@ pub enum StorageMediatorError {
DomainError(#[from] crate::common::errors::DomainError),
}
impl From<FolderRepositoryError> for StorageMediatorError {
fn from(err: FolderRepositoryError) -> Self {
match err {
FolderRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id),
FolderRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path),
FolderRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path),
FolderRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()),
_ => StorageMediatorError::InternalError(err.to_string()),
}
}
}
impl From<FileRepositoryError> for StorageMediatorError {
fn from(err: FileRepositoryError) -> Self {
match err {
FileRepositoryError::NotFound(id) => StorageMediatorError::NotFound(id),
FileRepositoryError::AlreadyExists(path) => StorageMediatorError::AlreadyExists(path),
FileRepositoryError::InvalidPath(path) => StorageMediatorError::InvalidPath(path),
FileRepositoryError::IoError(e) => StorageMediatorError::AccessError(e.to_string()),
_ => StorageMediatorError::InternalError(err.to_string()),
}
}
}
/// Tipo de resultado para las operaciones del mediador
pub type StorageMediatorResult<T> = Result<T, StorageMediatorError>;
@@ -98,113 +71,20 @@ pub trait StorageMediator: Send + Sync + 'static {
/// Implementación concreta del mediador de almacenamiento
pub struct FileSystemStorageMediator {
pub folder_repository: Arc<dyn FolderRepository>,
pub folder_storage_port: Arc<dyn FolderStoragePort>,
pub path_service: Arc<dyn StoragePort>,
pub id_mapping: Arc<dyn IdMappingPort>,
}
impl FileSystemStorageMediator {
pub fn new(folder_repository: Arc<dyn FolderRepository>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
Self { folder_repository, path_service, id_mapping }
pub fn new(folder_storage_port: Arc<dyn FolderStoragePort>, path_service: Arc<dyn StoragePort>, id_mapping: Arc<dyn IdMappingPort>) -> Self {
Self { folder_storage_port, path_service, id_mapping }
}
/// Creates a stub implementation for initialization bootstrapping
pub fn new_stub() -> StubStorageMediator {
StubStorageMediator::new()
}
/// Overload para implementar inicialización diferida con repository placeholder
pub fn new_with_lazy_folder(
_folder_repository: Arc<RwLock<Option<Arc<dyn FolderRepository>>>>,
path_service: Arc<dyn StoragePort>,
id_mapping: Arc<dyn IdMappingPort>
) -> Self {
// Create temporary stub repository
let temp_repo = Arc::new(FolderRepositoryStub {});
Self {
folder_repository: temp_repo,
path_service,
id_mapping,
}
}
}
/// Stub repository for initialization
#[derive(Debug)]
pub struct FolderRepositoryStub {}
#[async_trait]
impl FolderRepository for FolderRepositoryStub {
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn get_folder_by_id(&self, _id: &str) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn get_folder_by_storage_path(&self, _storage_path: &StoragePath) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn list_folders(&self, _parent_id: Option<&str>) -> Result<Vec<Folder>, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_offset: usize,
_limit: usize,
_include_total: bool
) -> Result<(Vec<Folder>, Option<usize>), FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn delete_folder(&self, _id: &str) -> Result<(), FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
async fn folder_exists_at_storage_path(&self, _storage_path: &StoragePath) -> Result<bool, FolderRepositoryError> {
Ok(false)
}
async fn get_folder_storage_path(&self, _id: &str) -> Result<StoragePath, FolderRepositoryError> {
Ok(StoragePath::root())
}
// Legacy methods
#[allow(deprecated)]
async fn folder_exists(&self, _path: &std::path::PathBuf) -> Result<bool, FolderRepositoryError> {
Ok(false)
}
#[allow(deprecated)]
async fn get_folder_by_path(&self, _path: &std::path::PathBuf) -> Result<Folder, FolderRepositoryError> {
Err(FolderRepositoryError::Other("Stub repository".to_string()))
}
// Trash functionality stubs
async fn move_to_trash(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> {
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
}
async fn restore_from_trash(&self, _folder_id: &str, _original_path: &str) -> Result<(), FolderRepositoryError> {
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
}
async fn delete_folder_permanently(&self, _folder_id: &str) -> Result<(), FolderRepositoryError> {
Err(FolderRepositoryError::OperationNotSupported("Trash feature temporarily disabled".to_string()))
}
}
/// Stub implementation for initialization dependency issues
@@ -276,7 +156,7 @@ impl StorageMediator for StubStorageMediator {
#[async_trait]
impl StorageMediator for FileSystemStorageMediator {
async fn get_folder_path(&self, folder_id: &str) -> StorageMediatorResult<PathBuf> {
let folder = self.folder_repository.get_folder_by_id(folder_id).await
let folder = self.folder_storage_port.get_folder(folder_id).await
.map_err(StorageMediatorError::from)?;
// Need to get the path from folder ID
@@ -289,7 +169,7 @@ impl StorageMediator for FileSystemStorageMediator {
}
async fn get_folder_storage_path(&self, folder_id: &str) -> StorageMediatorResult<StoragePath> {
let folder = self.folder_repository.get_folder_by_id(folder_id).await
let folder = self.folder_storage_port.get_folder(folder_id).await
.map_err(StorageMediatorError::from)?;
// Get path by folder ID - will already be a StoragePath
@@ -300,7 +180,7 @@ impl StorageMediator for FileSystemStorageMediator {
}
async fn get_folder(&self, folder_id: &str) -> StorageMediatorResult<Folder> {
let folder = self.folder_repository.get_folder_by_id(folder_id).await
let folder = self.folder_storage_port.get_folder(folder_id).await
.map_err(StorageMediatorError::from)?;
Ok(folder)
@@ -3,8 +3,7 @@ use async_trait::async_trait;
use tokio::task;
use crate::common::errors::DomainError;
use crate::application::ports::auth_ports::UserStoragePort;
use crate::application::ports::outbound::FileStoragePort;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::application::ports::storage_ports::{FileReadPort, StorageUsagePort};
use tracing::{info, error, debug};
/**
@@ -14,14 +13,14 @@ use tracing::{info, error, debug};
* is using and updating this information in the user records.
*/
pub struct StorageUsageService {
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
user_repository: Arc<dyn UserStoragePort>,
}
impl StorageUsageService {
/// Creates a new storage usage service
pub fn new(
file_repository: Arc<dyn FileStoragePort>,
file_repository: Arc<dyn FileReadPort>,
user_repository: Arc<dyn UserStoragePort>,
) -> Self {
Self {
@@ -90,7 +89,7 @@ impl StorageUsageService {
async fn calculate_folder_size(&self, folder_id: &str) -> Result<i64, DomainError> {
// Implementation with explicit boxing to handle recursion in async functions
async fn inner_calculate_size(
repo: Arc<dyn FileStoragePort>,
repo: Arc<dyn FileReadPort>,
folder_id: &str,
) -> Result<i64, DomainError> {
let mut total_size: i64 = 0;
+52 -48
View File
@@ -5,10 +5,10 @@ use tracing::{debug, error, info, instrument};
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::application::ports::outbound::FolderStoragePort;
use crate::common::errors::{Result, DomainError, ErrorKind};
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::file_repository::FileRepository;
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::domain::repositories::trash_repository::TrashRepository;
/**
@@ -20,7 +20,7 @@ use crate::domain::repositories::trash_repository::TrashRepository;
* repositories while enforcing business rules like retention policies.
*
* This service follows the Clean Architecture pattern by:
* - Depending on domain interfaces rather than concrete implementations
* - Depending on application ports rather than domain/infrastructure traits
* - Orchestrating domain operations without containing domain logic
* - Exposing its functionality through the TrashUseCase port
*/
@@ -28,11 +28,14 @@ pub struct TrashService {
/// Repository for trash-specific operations like listing and retrieving trashed items
trash_repository: Arc<dyn TrashRepository>,
/// Repository for file operations used when trashing, restoring, or deleting files
file_repository: Arc<dyn FileRepository>,
/// Port for file read operations (get file metadata)
file_read_port: Arc<dyn FileReadPort>,
/// Repository for folder operations used when trashing, restoring, or deleting folders
folder_repository: Arc<dyn FolderRepository>,
/// Port for file write operations (trash, restore, delete)
file_write_port: Arc<dyn FileWritePort>,
/// Port for folder operations (get folder, trash, restore, delete)
folder_storage_port: Arc<dyn FolderStoragePort>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
@@ -41,33 +44,35 @@ pub struct TrashService {
impl TrashService {
pub fn new(
trash_repository: Arc<dyn TrashRepository>,
file_repository: Arc<dyn FileRepository>,
folder_repository: Arc<dyn FolderRepository>,
file_read_port: Arc<dyn FileReadPort>,
file_write_port: Arc<dyn FileWritePort>,
folder_storage_port: Arc<dyn FolderStoragePort>,
retention_days: u32,
) -> Self {
Self {
trash_repository,
file_repository,
folder_repository,
file_read_port,
file_write_port,
folder_storage_port,
retention_days,
}
}
/// Converts a TrashedItem entity to a DTO
fn to_dto(&self, item: TrashedItem) -> TrashedItemDto {
// Calculate days_until_deletion before moving item.original_path
// Calculate days_until_deletion before moving item fields
let days_until_deletion = item.days_until_deletion();
TrashedItemDto {
id: item.id.to_string(),
original_id: item.original_id.to_string(),
item_type: match item.item_type {
id: item.id().to_string(),
original_id: item.original_id().to_string(),
item_type: match item.item_type() {
TrashedItemType::File => "file".to_string(),
TrashedItemType::Folder => "folder".to_string(),
},
name: item.name,
original_path: item.original_path,
trashed_at: item.trashed_at,
name: item.name().to_string(),
original_path: item.original_path().to_string(),
trashed_at: item.trashed_at(),
days_until_deletion,
}
}
@@ -83,10 +88,10 @@ impl TrashService {
match self.trash_repository.get_trash_item(&item_uuid, &user_uuid).await? {
Some(item) => {
if item.user_id != user_uuid {
if item.user_id() != user_uuid {
error!(
"User {} attempted to access trash item {} owned by {}",
user_id, item_id, item.user_id
user_id, item_id, item.user_id()
);
return Err(DomainError::access_denied(
"TrashItem",
@@ -130,10 +135,9 @@ impl TrashUseCase for TrashService {
info!("Moving to trash: type={}, id={}, user={}", item_type, item_id, user_id);
debug!("User UUID validation: {}", user_id);
// Validate user ownership
debug!("Validating user permissions");
self.validate_user_ownership(item_id, user_id).await?;
debug!("User permissions validated");
// Note: We do NOT call validate_user_ownership here because the item
// is not yet in the trash. Ownership validation is only for operations
// on already-trashed items (restore, delete_permanently).
// Parse UUIDs with detailed error handling
debug!("Validating item UUID: {}", item_id);
@@ -166,7 +170,7 @@ impl TrashUseCase for TrashService {
// Get the file to verify it exists and capture its data
debug!("Getting file data: {}", item_id);
let file = match self.file_repository.get_file_by_id(item_id).await {
let file = match self.file_read_port.get_file(item_id).await {
Ok(file) => {
debug!("File found: {} ({})", file.name(), item_id);
file
@@ -194,7 +198,7 @@ impl TrashUseCase for TrashService {
original_path,
self.retention_days,
);
debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id);
debug!("TrashedItem created successfully: {} -> {}", file.name(), trashed_item.id());
// First add to trash index to register the item
info!("Adding file {} to trash index", item_id);
@@ -210,7 +214,7 @@ impl TrashUseCase for TrashService {
// Then physically move the file to trash
info!("Physically moving file to trash: {}", item_id);
match self.file_repository.move_to_trash(item_id).await {
match self.file_write_port.move_to_trash(item_id).await {
Ok(_) => {
debug!("File physically moved to trash successfully: {}", item_id);
},
@@ -229,7 +233,7 @@ impl TrashUseCase for TrashService {
},
"folder" => {
// Get the folder to verify it exists and capture its data
let folder = self.folder_repository.get_folder_by_id(item_id).await
let folder = self.folder_storage_port.get_folder(item_id).await
.map_err(|e| DomainError::new(
ErrorKind::NotFound,
"Folder",
@@ -259,7 +263,7 @@ impl TrashUseCase for TrashService {
};
// Then physically move the folder to trash
self.folder_repository.move_to_trash(item_id).await
self.folder_storage_port.move_to_trash(item_id).await
.map_err(|e| DomainError::new(
ErrorKind::InternalError,
"Folder",
@@ -306,17 +310,17 @@ impl TrashUseCase for TrashService {
match item_result {
Ok(Some(item)) => {
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
trash_id, item.item_type, item.original_id);
trash_id, item.item_type(), item.original_id());
// Restore based on type
match item.item_type {
match item.item_type() {
TrashedItemType::File => {
// Restore the file to its original location
let file_id = item.original_id.to_string();
let original_path = item.original_path.clone();
let file_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
info!("Restoring file from trash: ID={}, OriginalPath={}", file_id, original_path);
match self.file_repository.restore_from_trash(&file_id, &original_path).await {
match self.file_write_port.restore_from_trash(&file_id, &original_path).await {
Ok(_) => {
info!("Successfully restored file from trash: {}", file_id);
},
@@ -339,11 +343,11 @@ impl TrashUseCase for TrashService {
},
TrashedItemType::Folder => {
// Restore the folder to its original location
let folder_id = item.original_id.to_string();
let original_path = item.original_path.clone();
let folder_id = item.original_id().to_string();
let original_path = item.original_path().to_string();
info!("Restoring folder from trash: ID={}, OriginalPath={}", folder_id, original_path);
match self.folder_repository.restore_from_trash(&folder_id, &original_path).await {
match self.folder_storage_port.restore_from_trash(&folder_id, &original_path).await {
Ok(_) => {
info!("Successfully restored folder from trash: {}", folder_id);
},
@@ -431,16 +435,16 @@ impl TrashUseCase for TrashService {
match item_result {
Ok(Some(item)) => {
info!("Found item in trash: ID={}, Type={:?}, OriginalID={}",
trash_id, item.item_type, item.original_id);
trash_id, item.item_type(), item.original_id());
// Permanently delete based on type
match item.item_type {
match item.item_type() {
TrashedItemType::File => {
// Eliminar el archivo permanentemente
let file_id = item.original_id.to_string();
let file_id = item.original_id().to_string();
info!("Permanently deleting file: {}", file_id);
match self.file_repository.delete_file_permanently(&file_id).await {
match self.file_write_port.delete_file_permanently(&file_id).await {
Ok(_) => {
info!("Successfully deleted file permanently: {}", file_id);
},
@@ -463,10 +467,10 @@ impl TrashUseCase for TrashService {
},
TrashedItemType::Folder => {
// Eliminar la carpeta permanentemente
let folder_id = item.original_id.to_string();
let folder_id = item.original_id().to_string();
info!("Permanently deleting folder: {}", folder_id);
match self.folder_repository.delete_folder_permanently(&folder_id).await {
match self.folder_storage_port.delete_folder_permanently(&folder_id).await {
Ok(_) => {
info!("Successfully deleted folder permanently: {}", folder_id);
},
@@ -532,18 +536,18 @@ impl TrashUseCase for TrashService {
// Permanently delete each item
for item in items {
match item.item_type {
match item.item_type() {
TrashedItemType::File => {
// Permanently delete the file
let file_id = item.original_id.to_string();
if let Err(e) = self.file_repository.delete_file_permanently(&file_id).await {
let file_id = item.original_id().to_string();
if let Err(e) = self.file_write_port.delete_file_permanently(&file_id).await {
error!("Error permanently deleting file {}: {}", file_id, e);
}
},
TrashedItemType::Folder => {
// Permanently delete the folder
let folder_id = item.original_id.to_string();
if let Err(e) = self.folder_repository.delete_folder_permanently(&folder_id).await {
let folder_id = item.original_id().to_string();
if let Err(e) = self.folder_storage_port.delete_folder_permanently(&folder_id).await {
error!("Error permanently deleting folder {}: {}", folder_id, e);
}
}
+177 -55
View File
@@ -1,17 +1,20 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::path::PathBuf;
use chrono::Utc;
use async_trait::async_trait;
use uuid::Uuid;
use bytes::Bytes;
use futures::Stream;
use crate::common::errors::{Result, DomainError};
use crate::domain::entities::file::File;
use crate::domain::entities::folder::Folder;
use crate::domain::entities::trashed_item::{TrashedItem, TrashedItemType};
use crate::domain::repositories::file_repository::{FileRepository, FileRepositoryResult, FileRepositoryError};
use crate::domain::repositories::folder_repository::{FolderRepository, FolderRepositoryResult, FolderRepositoryError};
use crate::domain::repositories::trash_repository::TrashRepository;
use crate::domain::services::path_service::StoragePath;
use crate::application::ports::storage_ports::{FileReadPort, FileWritePort};
use crate::domain::repositories::folder_repository::FolderRepository;
use crate::application::services::trash_service::TrashService;
// Mock repositories for testing
@@ -31,14 +34,14 @@ impl MockTrashRepository {
impl TrashRepository for MockTrashRepository {
async fn add_to_trash(&self, item: &TrashedItem) -> Result<()> {
let mut items = self.trash_items.lock().unwrap();
items.insert(item.id, item.clone());
items.insert(item.id(), item.clone());
Ok(())
}
async fn get_trash_items(&self, user_id: &Uuid) -> Result<Vec<TrashedItem>> {
let items = self.trash_items.lock().unwrap();
let user_items = items.values()
.filter(|item| item.user_id == *user_id)
.filter(|item| item.user_id() == *user_id)
.cloned()
.collect();
Ok(user_items)
@@ -47,7 +50,7 @@ impl TrashRepository for MockTrashRepository {
async fn get_trash_item(&self, id: &Uuid, user_id: &Uuid) -> Result<Option<TrashedItem>> {
let items = self.trash_items.lock().unwrap();
let item = items.get(id)
.filter(|item| item.user_id == *user_id)
.filter(|item| item.user_id() == *user_id)
.cloned();
Ok(item)
}
@@ -55,7 +58,7 @@ impl TrashRepository for MockTrashRepository {
async fn restore_from_trash(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
let mut items = self.trash_items.lock().unwrap();
if let Some(item) = items.get(id) {
if item.user_id == *user_id {
if item.user_id() == *user_id {
items.remove(id);
}
}
@@ -65,7 +68,7 @@ impl TrashRepository for MockTrashRepository {
async fn delete_permanently(&self, id: &Uuid, user_id: &Uuid) -> Result<()> {
let mut items = self.trash_items.lock().unwrap();
if let Some(item) = items.get(id) {
if item.user_id == *user_id {
if item.user_id() == *user_id {
items.remove(id);
}
}
@@ -74,7 +77,7 @@ impl TrashRepository for MockTrashRepository {
async fn clear_trash(&self, user_id: &Uuid) -> Result<()> {
let mut items = self.trash_items.lock().unwrap();
items.retain(|_, item| item.user_id != *user_id);
items.retain(|_, item| item.user_id() != *user_id);
Ok(())
}
@@ -82,7 +85,7 @@ impl TrashRepository for MockTrashRepository {
let items = self.trash_items.lock().unwrap();
let now = Utc::now();
let expired = items.values()
.filter(|item| item.deletion_date <= now)
.filter(|item| item.deletion_date() <= now)
.cloned()
.collect();
Ok(expired)
@@ -118,17 +121,102 @@ impl MockFileRepository {
}
#[async_trait]
impl FileRepository for MockFileRepository {
async fn get_file_by_id(&self, id: &str) -> FileRepositoryResult<File> {
impl FileReadPort for MockFileRepository {
async fn get_file(&self, id: &str) -> std::result::Result<File, DomainError> {
let files = self.files.lock().unwrap();
if let Some(file) = files.get(id) {
Ok(file.clone())
} else {
Err(FileRepositoryError::NotFound(id.to_string()))
Err(DomainError::not_found("File", id.to_string()))
}
}
async fn move_to_trash(&self, id: &str) -> FileRepositoryResult<()> {
async fn list_files(&self, _folder_id: Option<&str>) -> std::result::Result<Vec<File>, DomainError> {
Ok(vec![])
}
async fn get_file_content(&self, _id: &str) -> std::result::Result<Vec<u8>, DomainError> {
Ok(vec![])
}
async fn get_file_stream(
&self,
_id: &str,
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn get_file_range_stream(
&self,
_id: &str,
_start: u64,
_end: Option<u64>,
) -> std::result::Result<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>, DomainError> {
unimplemented!()
}
async fn get_file_mmap(&self, _id: &str) -> std::result::Result<Bytes, DomainError> {
unimplemented!()
}
async fn get_file_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
unimplemented!()
}
async fn get_parent_folder_id(&self, _path: &str) -> std::result::Result<String, DomainError> {
unimplemented!()
}
}
#[async_trait]
impl FileWritePort for MockFileRepository {
async fn save_file(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_content: Vec<u8>,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
async fn save_file_from_stream(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_stream: std::pin::Pin<Box<dyn Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send>>,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
async fn move_file(
&self,
_file_id: &str,
_target_folder_id: Option<String>,
) -> std::result::Result<File, DomainError> {
unimplemented!()
}
async fn delete_file(&self, _id: &str) -> std::result::Result<(), DomainError> {
Ok(())
}
async fn update_file_content(&self, _file_id: &str, _content: Vec<u8>) -> std::result::Result<(), DomainError> {
Ok(())
}
async fn register_file_deferred(
&self,
_name: String,
_folder_id: Option<String>,
_content_type: String,
_size: u64,
) -> std::result::Result<(File, PathBuf), DomainError> {
unimplemented!()
}
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
let mut files = self.files.lock().unwrap();
let mut trashed = self.trashed_files.lock().unwrap();
@@ -136,11 +224,11 @@ impl FileRepository for MockFileRepository {
trashed.insert(id.to_string(), file);
Ok(())
} else {
Err(FileRepositoryError::NotFound(id.to_string()))
Err(DomainError::not_found("File", id.to_string()))
}
}
async fn restore_from_trash(&self, id: &str, original_path: &str) -> FileRepositoryResult<()> {
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
let mut files = self.files.lock().unwrap();
let mut trashed = self.trashed_files.lock().unwrap();
@@ -148,26 +236,18 @@ impl FileRepository for MockFileRepository {
files.insert(id.to_string(), file);
Ok(())
} else {
Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id)))
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
}
}
async fn delete_file_permanently(&self, id: &str) -> FileRepositoryResult<()> {
async fn delete_file_permanently(&self, id: &str) -> std::result::Result<(), DomainError> {
let mut trashed = self.trashed_files.lock().unwrap();
if trashed.remove(id).is_some() {
Ok(())
} else {
Err(FileRepositoryError::NotFound(format!("File {} not found in trash", id)))
Err(DomainError::not_found("File", format!("File {} not found in trash", id)))
}
}
// Other methods required by the trait (not used in tests)
async fn save_file(&self, _file: &File) -> FileRepositoryResult<()> { Ok(()) }
async fn delete_file(&self, _id: &str) -> FileRepositoryResult<()> { Ok(()) }
async fn get_files_in_folder(&self, _folder_id: Option<&str>) -> FileRepositoryResult<Vec<File>> { Ok(vec![]) }
async fn move_file(&self, _id: &str, _new_folder_id: Option<&str>) -> FileRepositoryResult<()> { Ok(()) }
async fn update_file_data(&self, _id: &str, _new_data: &[u8]) -> FileRepositoryResult<()> { Ok(()) }
async fn get_file_data(&self, _id: &str) -> FileRepositoryResult<Vec<u8>> { Ok(vec![]) }
}
struct MockFolderRepository {
@@ -198,16 +278,58 @@ impl MockFolderRepository {
#[async_trait]
impl FolderRepository for MockFolderRepository {
async fn get_folder_by_id(&self, id: &str) -> FolderRepositoryResult<Folder> {
async fn create_folder(&self, _name: String, _parent_id: Option<String>) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
async fn get_folder(&self, id: &str) -> std::result::Result<Folder, DomainError> {
let folders = self.folders.lock().unwrap();
if let Some(folder) = folders.get(id) {
Ok(folder.clone())
} else {
Err(FolderRepositoryError::NotFound(id.to_string()))
Err(DomainError::not_found("Folder", id.to_string()))
}
}
async fn move_to_trash(&self, id: &str) -> FolderRepositoryResult<()> {
async fn get_folder_by_path(&self, _storage_path: &StoragePath) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
async fn list_folders(&self, _parent_id: Option<&str>) -> std::result::Result<Vec<Folder>, DomainError> {
Ok(vec![])
}
async fn list_folders_paginated(
&self,
_parent_id: Option<&str>,
_offset: usize,
_limit: usize,
_include_total: bool,
) -> std::result::Result<(Vec<Folder>, Option<usize>), DomainError> {
Ok((vec![], Some(0)))
}
async fn rename_folder(&self, _id: &str, _new_name: String) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> std::result::Result<Folder, DomainError> {
unimplemented!()
}
async fn delete_folder(&self, _id: &str) -> std::result::Result<(), DomainError> {
Ok(())
}
async fn folder_exists(&self, _storage_path: &StoragePath) -> std::result::Result<bool, DomainError> {
Ok(false)
}
async fn get_folder_path(&self, _id: &str) -> std::result::Result<StoragePath, DomainError> {
Ok(StoragePath::from_string("/"))
}
async fn move_to_trash(&self, id: &str) -> std::result::Result<(), DomainError> {
let mut folders = self.folders.lock().unwrap();
let mut trashed = self.trashed_folders.lock().unwrap();
@@ -215,11 +337,11 @@ impl FolderRepository for MockFolderRepository {
trashed.insert(id.to_string(), folder);
Ok(())
} else {
Err(FolderRepositoryError::NotFound(id.to_string()))
Err(DomainError::not_found("Folder", id.to_string()))
}
}
async fn restore_from_trash(&self, id: &str, original_path: &str) -> FolderRepositoryResult<()> {
async fn restore_from_trash(&self, id: &str, _original_path: &str) -> std::result::Result<(), DomainError> {
let mut folders = self.folders.lock().unwrap();
let mut trashed = self.trashed_folders.lock().unwrap();
@@ -227,29 +349,24 @@ impl FolderRepository for MockFolderRepository {
folders.insert(id.to_string(), folder);
Ok(())
} else {
Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id)))
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
}
}
async fn delete_folder_permanently(&self, id: &str) -> FolderRepositoryResult<()> {
async fn delete_folder_permanently(&self, id: &str) -> std::result::Result<(), DomainError> {
let mut trashed = self.trashed_folders.lock().unwrap();
if trashed.remove(id).is_some() {
Ok(())
} else {
Err(FolderRepositoryError::NotFound(format!("Folder {} not found in trash", id)))
Err(DomainError::not_found("Folder", format!("Folder {} not found in trash", id)))
}
}
// Other methods required by the trait (not used in tests)
async fn save_folder(&self, _folder: &Folder) -> FolderRepositoryResult<()> { Ok(()) }
async fn delete_folder(&self, _id: &str) -> FolderRepositoryResult<()> { Ok(()) }
async fn get_folders_in_folder(&self, _parent_id: Option<&str>) -> FolderRepositoryResult<Vec<Folder>> { Ok(vec![]) }
async fn move_folder(&self, _id: &str, _new_parent_id: Option<&str>) -> FolderRepositoryResult<()> { Ok(()) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::application::ports::trash_ports::TrashUseCase;
#[tokio::test]
async fn test_move_file_to_trash() {
@@ -260,7 +377,8 @@ mod tests {
let service = TrashService::new(
trash_repo.clone(),
file_repo.clone(),
file_repo.clone() as Arc<dyn FileReadPort>,
file_repo.clone() as Arc<dyn FileWritePort>,
folder_repo.clone(),
30, // 30 days retention
);
@@ -284,10 +402,10 @@ mod tests {
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
let trash_item = &trash_items[0];
assert_eq!(trash_item.original_id.to_string(), file_id, "Original ID should match file ID");
assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match");
assert_eq!(trash_item.item_type, TrashedItemType::File, "Item type should be File");
assert_eq!(trash_item.name, "test.txt", "File name should match");
assert_eq!(trash_item.original_id().to_string(), file_id, "Original ID should match file ID");
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
assert_eq!(*trash_item.item_type(), TrashedItemType::File, "Item type should be File");
assert_eq!(trash_item.name(), "test.txt", "File name should match");
// Verify file is moved in file repository
let files = file_repo.files.lock().unwrap();
@@ -306,7 +424,8 @@ mod tests {
let service = TrashService::new(
trash_repo.clone(),
file_repo.clone(),
file_repo.clone() as Arc<dyn FileReadPort>,
file_repo.clone() as Arc<dyn FileWritePort>,
folder_repo.clone(),
30, // 30 days retention
);
@@ -330,10 +449,10 @@ mod tests {
assert_eq!(trash_items.len(), 1, "Should have exactly one item in trash");
let trash_item = &trash_items[0];
assert_eq!(trash_item.original_id.to_string(), folder_id, "Original ID should match folder ID");
assert_eq!(trash_item.user_id.to_string(), user_id, "User ID should match");
assert_eq!(trash_item.item_type, TrashedItemType::Folder, "Item type should be Folder");
assert_eq!(trash_item.name, "test_folder", "Folder name should match");
assert_eq!(trash_item.original_id().to_string(), folder_id, "Original ID should match folder ID");
assert_eq!(trash_item.user_id().to_string(), user_id, "User ID should match");
assert_eq!(*trash_item.item_type(), TrashedItemType::Folder, "Item type should be Folder");
assert_eq!(trash_item.name(), "test_folder", "Folder name should match");
}
#[tokio::test]
@@ -345,7 +464,8 @@ mod tests {
let service = TrashService::new(
trash_repo.clone(),
file_repo.clone(),
file_repo.clone() as Arc<dyn FileReadPort>,
file_repo.clone() as Arc<dyn FileWritePort>,
folder_repo.clone(),
30, // 30 days retention
);
@@ -361,7 +481,7 @@ mod tests {
// Get the trash item ID
let user_uuid = Uuid::parse_str(user_id).unwrap();
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
let trash_id = trash_items[0].id.to_string();
let trash_id = trash_items[0].id().to_string();
// Act
let result = service.restore_item(&trash_id, user_id).await;
@@ -390,7 +510,8 @@ mod tests {
let service = TrashService::new(
trash_repo.clone(),
file_repo.clone(),
file_repo.clone() as Arc<dyn FileReadPort>,
file_repo.clone() as Arc<dyn FileWritePort>,
folder_repo.clone(),
30, // 30 days retention
);
@@ -405,7 +526,7 @@ mod tests {
// Get the trash item ID
let user_uuid = Uuid::parse_str(user_id).unwrap();
let trash_items = trash_repo.get_trash_items(&user_uuid).await.unwrap();
let trash_id = trash_items[0].id.to_string();
let trash_id = trash_items[0].id().to_string();
// Act
let result = service.delete_permanently(&trash_id, user_id).await;
@@ -434,7 +555,8 @@ mod tests {
let service = TrashService::new(
trash_repo.clone(),
file_repo.clone(),
file_repo.clone() as Arc<dyn FileReadPort>,
file_repo.clone() as Arc<dyn FileWritePort>,
folder_repo.clone(),
30, // 30 days retention
);