refactoring hexagonal and clean architecture
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]
|
||||
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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/"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user