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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
35
crates/application/src/catalog/commands/restore_asset.rs
Normal file
35
crates/application/src/catalog/commands/restore_asset.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
34
crates/application/src/catalog/queries/list_trash.rs
Normal file
34
crates/application/src/catalog/queries/list_trash.rs
Normal 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 })
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
31
crates/application/src/organization/commands/delete_album.rs
Normal file
31
crates/application/src/organization/commands/delete_album.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user