fix: delete thumbnails when files are permanently deleted

- Inject ThumbnailService into TrashService and FileManagementService
- Call delete_thumbnails() after permanent file deletion in:
  - TrashService::delete_permanently (single item)
  - TrashService::empty_trash (bulk: collects file IDs first)
  - FileManagementService::delete_file
  - FileManagementService::delete_with_cleanup (fallback path)
- All thumbnail cleanup is best-effort (warn on failure, never blocks)
- Prevents orphaned thumbnail files from accumulating on disk
This commit is contained in:
Diocrafts
2026-03-07 19:15:36 +01:00
parent 2aeb97383c
commit 661c9cb688
3 changed files with 66 additions and 3 deletions
@@ -8,6 +8,7 @@ use crate::application::services::trash_service::TrashService;
use crate::common::errors::DomainError;
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
use tracing::{error, info, warn};
use uuid::Uuid;
@@ -21,6 +22,7 @@ pub struct FileManagementService {
file_repository: Arc<FileBlobWriteRepository>,
file_read: Option<Arc<FileBlobReadRepository>>,
trash_service: Option<Arc<TrashService>>,
thumbnail_service: Option<Arc<ThumbnailService>>,
}
impl FileManagementService {
@@ -30,6 +32,7 @@ impl FileManagementService {
file_repository,
file_read: None,
trash_service: None,
thumbnail_service: None,
}
}
@@ -38,11 +41,13 @@ impl FileManagementService {
file_repository: Arc<FileBlobWriteRepository>,
trash_service: Option<Arc<TrashService>>,
file_read: Option<Arc<FileBlobReadRepository>>,
thumbnail_service: Option<Arc<ThumbnailService>>,
) -> Self {
Self {
file_repository,
file_read,
trash_service,
thumbnail_service,
}
}
@@ -171,7 +176,14 @@ impl FileManagementUseCase for FileManagementService {
}
async fn delete_file(&self, id: &str) -> Result<(), DomainError> {
self.file_repository.delete_file(id).await
self.file_repository.delete_file(id).await?;
// Best-effort thumbnail cleanup
if let Some(thumb) = &self.thumbnail_service {
if let Err(e) = thumb.delete_thumbnails(id).await {
warn!("Failed to delete thumbnails for file {}: {}", id, e);
}
}
Ok(())
}
async fn delete_file_owned(&self, id: &str, caller_id: Uuid) -> Result<(), DomainError> {
@@ -210,6 +222,12 @@ impl FileManagementUseCase for FileManagementService {
// Step 2: Permanent delete — trigger handles blob ref_count
warn!("Permanently deleting file: {}", id);
self.file_repository.delete_file(id).await?;
// Best-effort thumbnail cleanup
if let Some(thumb) = &self.thumbnail_service {
if let Err(e) = thumb.delete_thumbnails(id).await {
warn!("Failed to delete thumbnails for file {}: {}", id, e);
}
}
info!("File permanently deleted: {}", id);
Ok(false) // permanently deleted
+43 -1
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use tracing::{debug, error, info, instrument};
use tracing::{debug, error, info, instrument, warn};
use uuid::Uuid;
use crate::application::dtos::trash_dto::TrashedItemDto;
@@ -13,6 +13,7 @@ use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlob
use crate::infrastructure::repositories::pg::file_blob_write_repository::FileBlobWriteRepository;
use crate::infrastructure::repositories::pg::folder_db_repository::FolderDbRepository;
use crate::infrastructure::repositories::pg::trash_db_repository::TrashDbRepository;
use crate::infrastructure::services::thumbnail_service::ThumbnailService;
/**
* Application service for trash operations.
@@ -40,6 +41,9 @@ pub struct TrashService {
/// Port for folder operations (get folder, trash, restore, delete)
folder_storage_port: Arc<FolderDbRepository>,
/// Thumbnail service for cleaning up thumbnails on permanent delete
thumbnail_service: Option<Arc<ThumbnailService>>,
/// Number of days items should be kept in trash before automatic cleanup
retention_days: u32,
}
@@ -51,12 +55,14 @@ impl TrashService {
file_write_port: Arc<FileBlobWriteRepository>,
folder_storage_port: Arc<FolderDbRepository>,
retention_days: u32,
thumbnail_service: Option<Arc<ThumbnailService>>,
) -> Self {
Self {
trash_repository,
file_read_port,
file_write_port,
folder_storage_port,
thumbnail_service,
retention_days,
}
}
@@ -557,6 +563,14 @@ impl TrashUseCase for TrashService {
}
}
}
// Best-effort thumbnail cleanup — thumbnails are cache
// artifacts, so failure must not block file deletion.
if let Some(thumb) = &self.thumbnail_service {
if let Err(e) = thumb.delete_thumbnails(&file_id).await {
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
}
}
TrashedItemType::Folder => {
// Permanently delete the folder
@@ -647,6 +661,25 @@ impl TrashUseCase for TrashService {
async fn empty_trash(&self, user_id: Uuid) -> Result<()> {
info!("Emptying trash for user {}", user_id);
// Collect trashed file IDs BEFORE bulk-deleting so we can clean up
// their thumbnails afterward. This is best-effort — if the query
// fails we still proceed with the bulk delete.
let trashed_file_ids: Vec<String> = if self.thumbnail_service.is_some() {
match self.trash_repository.get_trash_items(&user_id).await {
Ok(items) => items
.iter()
.filter(|i| matches!(i.item_type(), TrashedItemType::File))
.map(|i| i.original_id().to_string())
.collect(),
Err(e) => {
warn!("Could not list trashed items for thumbnail cleanup: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
// clear_trash() already performs bulk SQL DELETEs in 2 queries:
// 1. DELETE FROM storage.files WHERE user_id = $1 AND is_trashed = TRUE
// 2. DELETE FROM storage.folders WHERE user_id = $1 AND is_trashed = TRUE
@@ -659,6 +692,15 @@ impl TrashUseCase for TrashService {
// Finally it clears the trash_items index for the user.
self.trash_repository.clear_trash(&user_id).await?;
// Best-effort thumbnail cleanup for all deleted files
if let Some(thumb) = &self.thumbnail_service {
for file_id in &trashed_file_ids {
if let Err(e) = thumb.delete_thumbnails(file_id).await {
warn!("Failed to delete thumbnails for file {}: {}", file_id, e);
}
}
}
info!("Trash emptied for user {}", user_id);
Ok(())
}
+4 -1
View File
@@ -261,6 +261,7 @@ impl AppServiceFactory {
repos.file_write_repository.clone(),
trash_service.clone(),
Some(repos.file_read_repository.clone()),
Some(core.thumbnail_service.clone()),
));
let file_use_case_factory = Arc::new(AppFileUseCaseFactory::new(
@@ -302,6 +303,7 @@ impl AppServiceFactory {
pub async fn create_trash_service(
&self,
repos: &RepositoryServices,
core: &CoreServices,
) -> Option<Arc<TrashService>> {
if !self.config.features.enable_trash {
tracing::info!("Trash service is disabled in configuration");
@@ -317,6 +319,7 @@ impl AppServiceFactory {
repos.file_write_repository.clone(),
repos.folder_repository.clone(),
self.config.storage.trash_retention_days,
Some(core.thumbnail_service.clone()),
));
// Initialize cleanup service (bulk-deletes expired items in 2 SQL queries)
@@ -457,7 +460,7 @@ impl AppServiceFactory {
let repos = self.create_repository_services(&core, &pool);
// 3. Trash service (needed before application services)
let trash_service = self.create_trash_service(&repos).await;
let trash_service = self.create_trash_service(&repos, &core).await;
// 4. Application services (with trash already wired)
let mut apps = self.create_application_services(&core, &repos, trash_service.clone());