feat: safe deletion, album/asset delete, trash, README update

- volume-aware deletion: read-only volumes remove DB only, writable
  volumes soft-delete to trash with configurable grace period
- trash page with restore, worker purge sweep (TRASH_RETENTION_DAYS)
- album delete endpoint + sidebar trash icon
- asset delete from timeline selection toolbar
- all listing queries exclude trashed assets (deleted_at IS NULL)
- timeline ordered by EXIF capture date, date-summary endpoint
- README rewritten with features, setup, full env var table
This commit is contained in:
2026-06-01 01:57:53 +02:00
parent 957737ac9b
commit 0077caa743
36 changed files with 752 additions and 125 deletions

View File

@@ -5,6 +5,7 @@ use domain::{
events::DomainEvent,
ports::{
AssetRepository, DerivativeRepository, EventPublisher, FileStoragePort, SidecarRepository,
StorageVolumeRepository,
},
value_objects::{DateTimeStamp, SystemId},
};
@@ -16,6 +17,7 @@ pub struct DeleteAssetCommand {
pub struct DeleteAssetHandler {
asset_repo: Arc<dyn AssetRepository>,
volume_repo: Arc<dyn StorageVolumeRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
@@ -25,6 +27,7 @@ pub struct DeleteAssetHandler {
impl DeleteAssetHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
volume_repo: Arc<dyn StorageVolumeRepository>,
derivative_repo: Arc<dyn DerivativeRepository>,
sidecar_repo: Arc<dyn SidecarRepository>,
file_storage: Arc<dyn FileStoragePort>,
@@ -32,6 +35,7 @@ impl DeleteAssetHandler {
) -> Self {
Self {
asset_repo,
volume_repo,
derivative_repo,
sidecar_repo,
file_storage,
@@ -46,31 +50,24 @@ impl DeleteAssetHandler {
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
// Delete derivative files + DB records
let derivatives = self.derivative_repo.find_by_asset(&cmd.asset_id).await?;
for d in &derivatives {
let _ = self.file_storage.delete_file(&d.storage_path).await;
self.derivative_repo.delete(&d.derivative_id).await?;
let volume = self
.volume_repo
.find_by_id(&asset.source_reference.volume_id)
.await?
.ok_or_else(|| DomainError::NotFound("Volume not found".into()))?;
if volume.is_writable {
// Writable volume: soft-delete, keep files for grace period
self.asset_repo
.soft_delete(&cmd.asset_id, &cmd.deleted_by)
.await?;
} else {
// Read-only volume: remove DB records + derivatives, never touch original
self.cleanup_derivatives(&cmd.asset_id).await?;
self.cleanup_sidecar(&cmd.asset_id).await?;
self.asset_repo.delete(&cmd.asset_id).await?;
}
// Delete sidecar file + DB record
if let Some(sidecar) = self.sidecar_repo.find_by_asset(&cmd.asset_id).await? {
let _ = self
.file_storage
.delete_file(&sidecar.sidecar_storage_path)
.await;
self.sidecar_repo.delete(&cmd.asset_id).await?;
}
// Delete asset file
let _ = self
.file_storage
.delete_file(&asset.source_reference.relative_path)
.await;
// Delete asset DB record
self.asset_repo.delete(&cmd.asset_id).await?;
self.event_publisher
.publish(&DomainEvent::AssetDeleted {
asset_id: cmd.asset_id,
@@ -81,4 +78,51 @@ impl DeleteAssetHandler {
Ok(())
}
pub async fn purge(&self, asset_id: &SystemId) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
self.cleanup_derivatives(asset_id).await?;
self.cleanup_sidecar(asset_id).await?;
let volume = self
.volume_repo
.find_by_id(&asset.source_reference.volume_id)
.await?;
if let Some(v) = volume {
if v.is_writable {
let _ = self
.file_storage
.delete_file(&asset.source_reference.relative_path)
.await;
}
}
self.asset_repo.delete(asset_id).await?;
Ok(())
}
async fn cleanup_derivatives(&self, asset_id: &SystemId) -> Result<(), DomainError> {
let derivatives = self.derivative_repo.find_by_asset(asset_id).await?;
for d in &derivatives {
let _ = self.file_storage.delete_file(&d.storage_path).await;
self.derivative_repo.delete(&d.derivative_id).await?;
}
Ok(())
}
async fn cleanup_sidecar(&self, asset_id: &SystemId) -> Result<(), DomainError> {
if let Some(sidecar) = self.sidecar_repo.find_by_asset(asset_id).await? {
let _ = self
.file_storage
.delete_file(&sidecar.sidecar_storage_path)
.await;
self.sidecar_repo.delete(asset_id).await?;
}
Ok(())
}
}

View File

@@ -1,5 +1,6 @@
pub mod create_stack;
pub mod delete_asset;
pub mod restore_asset;
pub mod detect_live_photos;
pub mod register_asset;
pub mod resolve_duplicate;

View File

@@ -0,0 +1,35 @@
use domain::{errors::DomainError, ports::AssetRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct RestoreAssetCommand {
pub asset_id: SystemId,
pub user_id: SystemId,
}
pub struct RestoreAssetHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl RestoreAssetHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, cmd: RestoreAssetCommand) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(&cmd.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
if asset.owner_user_id != cmd.user_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
if !asset.is_deleted() {
return Err(DomainError::Validation("Asset is not trashed".into()));
}
self.asset_repo.restore(&cmd.asset_id).await
}
}

View File

@@ -6,6 +6,7 @@ pub use commands::create_stack::{
CreateStackCommand, CreateStackHandler, DeleteStackCommand, DeleteStackHandler,
};
pub use commands::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
pub use commands::restore_asset::{RestoreAssetCommand, RestoreAssetHandler};
pub use commands::detect_live_photos::{DetectLivePhotosCommand, DetectLivePhotosHandler};
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
pub use commands::resolve_duplicate::{
@@ -17,6 +18,7 @@ pub use queries::get_date_summary::{DateSummaryEntry, GetDateSummaryHandler, Get
pub use queries::get_stack::{GetStackHandler, GetStackQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};
pub use queries::list_trash::{ListTrashHandler, ListTrashQuery, TrashResult};
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,

View File

@@ -0,0 +1,34 @@
use domain::{
entities::Asset, errors::DomainError, ports::AssetRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct ListTrashQuery {
pub owner_id: SystemId,
pub limit: u32,
pub offset: u32,
}
pub struct TrashResult {
pub assets: Vec<Asset>,
pub total: u64,
}
pub struct ListTrashHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl ListTrashHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, query: ListTrashQuery) -> Result<TrashResult, DomainError> {
let total = self.asset_repo.count_trashed(&query.owner_id).await?;
let assets = self
.asset_repo
.find_trashed_by_owner(&query.owner_id, query.limit, query.offset)
.await?;
Ok(TrashResult { assets, total })
}
}

View File

@@ -1,5 +1,6 @@
pub mod get_asset;
pub mod get_date_summary;
pub mod list_trash;
pub mod get_stack;
pub mod get_timeline;
pub mod list_stacks;

View File

@@ -148,6 +148,26 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
self.inner.delete(id).await
}
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
self.inner.soft_delete(id, deleted_by).await
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
self.inner.restore(id).await
}
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
self.inner.find_trashed_before(cutoff).await
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
self.inner.count_trashed(owner_id).await
}
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
self.inner.find_trashed_by_owner(owner_id, limit, offset).await
}
}
#[cfg(test)]

View File

@@ -0,0 +1,31 @@
use domain::{errors::DomainError, ports::AlbumRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct DeleteAlbumCommand {
pub album_id: SystemId,
pub user_id: SystemId,
}
pub struct DeleteAlbumHandler {
repo: Arc<dyn AlbumRepository>,
}
impl DeleteAlbumHandler {
pub fn new(repo: Arc<dyn AlbumRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self, cmd: DeleteAlbumCommand) -> Result<(), DomainError> {
let album = self
.repo
.find_by_id(&cmd.album_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Album {} not found", cmd.album_id)))?;
if album.creator_user_id != cmd.user_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
self.repo.delete(&cmd.album_id).await
}
}

View File

@@ -1,9 +1,11 @@
pub mod create_album;
pub mod delete_album;
pub mod manage_album_entries;
pub mod tag_asset;
pub mod update_album;
pub use create_album::{CreateAlbumCommand, CreateAlbumHandler};
pub use delete_album::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use tag_asset::{TagAssetCommand, TagAssetHandler};
pub use update_album::{UpdateAlbumCommand, UpdateAlbumHandler};

View File

@@ -3,6 +3,7 @@ pub mod queries;
pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use commands::{CreateAlbumCommand, CreateAlbumHandler};
pub use commands::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use commands::{TagAssetCommand, TagAssetHandler};
pub use commands::{UpdateAlbumCommand, UpdateAlbumHandler};
pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery};

View File

@@ -204,6 +204,39 @@ impl AssetRepository for InMemoryAssetRepository {
self.data.lock().await.remove(&id.to_string());
Ok(())
}
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
asset.trash(*deleted_by);
}
Ok(())
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
asset.restore();
}
Ok(())
}
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| a.deleted_at.as_ref().map_or(false, |d| *d.as_datetime() < cutoff))
.cloned().collect())
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
.count() as u64)
}
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
Ok(self.data.lock().await.values()
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
.skip(offset as usize).take(limit as usize)
.cloned().collect())
}
}
in_memory_repo!(InMemoryAlbumRepository, Album);