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

@@ -0,0 +1,3 @@
ALTER TABLE assets ADD COLUMN deleted_at TIMESTAMPTZ NULL;
ALTER TABLE assets ADD COLUMN deleted_by UUID NULL REFERENCES users(id);
CREATE INDEX idx_assets_deleted ON assets (deleted_at) WHERE deleted_at IS NOT NULL;

View File

@@ -33,6 +33,8 @@ struct AssetRow {
is_processed: bool,
owner_user_id: Uuid,
created_at: DateTime<Utc>,
deleted_at: Option<DateTime<Utc>>,
deleted_by: Option<Uuid>,
}
fn asset_type_from_str(s: &str) -> AssetType {
@@ -68,6 +70,8 @@ impl TryFrom<AssetRow> for Asset {
is_processed: r.is_processed,
owner_user_id: SystemId::from_uuid(r.owner_user_id),
created_at: DateTimeStamp::from_datetime(r.created_at),
deleted_at: r.deleted_at.map(DateTimeStamp::from_datetime),
deleted_by: r.deleted_by.map(SystemId::from_uuid),
})
}
}
@@ -157,7 +161,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
let row = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE asset_id = $1",
)
.bind(*id.as_uuid())
@@ -171,7 +175,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE checksum = $1",
)
.bind(checksum.as_str())
@@ -190,11 +194,11 @@ impl AssetRepository for PostgresAssetRepository {
) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
a.file_size, a.is_processed, a.owner_user_id, a.created_at
a.file_size, a.is_processed, a.owner_user_id, a.created_at, a.deleted_at, a.deleted_by
FROM assets a
LEFT JOIN asset_metadata am
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
WHERE a.owner_user_id = $1
WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL
ORDER BY COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
@@ -221,8 +225,8 @@ impl AssetRepository for PostgresAssetRepository {
let (where_clause, has_tag) = build_search_where(filters);
let mut sql = format!(
"SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
a.file_size, a.is_processed, a.owner_user_id, a.created_at
FROM assets a{} WHERE a.owner_user_id = $1{}",
a.file_size, a.is_processed, a.owner_user_id, a.created_at, a.deleted_at, a.deleted_by
FROM assets a{} WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL{}",
if has_tag {
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
} else {
@@ -253,7 +257,7 @@ impl AssetRepository for PostgresAssetRepository {
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
let (count,): (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1")
sqlx::query_as("SELECT COUNT(*) FROM assets WHERE owner_user_id = $1 AND deleted_at IS NULL")
.bind(*owner_id.as_uuid())
.fetch_one(&self.pool)
.await
@@ -268,7 +272,7 @@ impl AssetRepository for PostgresAssetRepository {
) -> Result<u64, DomainError> {
let (where_clause, has_tag) = build_search_where(filters);
let sql = format!(
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1{}",
"SELECT COUNT(*) FROM assets a{} WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL{}",
if has_tag {
" JOIN asset_tags at ON at.asset_id = a.asset_id JOIN tags t ON t.tag_id = at.tag_id"
} else {
@@ -315,7 +319,7 @@ impl AssetRepository for PostgresAssetRepository {
FROM assets a
LEFT JOIN asset_metadata am
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
WHERE a.owner_user_id = $1
WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL
GROUP BY day ORDER BY day DESC",
)
.bind(*owner_id.as_uuid())
@@ -365,6 +369,84 @@ impl AssetRepository for PostgresAssetRepository {
.map_pg()?;
Ok(())
}
async fn soft_delete(
&self,
id: &SystemId,
deleted_by: &SystemId,
) -> Result<(), DomainError> {
sqlx::query(
"UPDATE assets SET deleted_at = NOW(), deleted_by = $2 WHERE asset_id = $1",
)
.bind(*id.as_uuid())
.bind(*deleted_by.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query(
"UPDATE assets SET deleted_at = NULL, deleted_by = NULL WHERE asset_id = $1",
)
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn find_trashed_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE deleted_at IS NOT NULL AND deleted_at < $1",
)
.bind(cutoff)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
let (count,): (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM assets WHERE owner_user_id = $1 AND deleted_at IS NOT NULL",
)
.bind(*owner_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_pg()?;
Ok(count as u64)
}
async fn find_trashed_by_owner(
&self,
owner_id: &SystemId,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
file_size, is_processed, owner_user_id, created_at, deleted_at, deleted_by
FROM assets WHERE owner_user_id = $1 AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC
LIMIT $2 OFFSET $3",
)
.bind(*owner_id.as_uuid())
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
}
// ──────────────────────────────────────────────

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);

View File

@@ -10,9 +10,9 @@ use domain::ports::FileStoragePort;
use application::catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler,
ListDuplicatesHandler, ListTrashHandler,
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
SearchAssetsHandler, UpdateMetadataHandler,
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
};
use application::storage::IngestAssetHandler;
use domain::ports::EventPublisher;
@@ -77,12 +77,16 @@ pub fn build(
let delete_asset = Arc::new(DeleteAssetHandler::new(
asset_repo.clone(),
storage_repos.volume_repo.clone(),
derivative_repo.clone(),
sidecar_repo,
file_storage.clone(),
event_publisher.clone(),
));
let restore_asset = Arc::new(RestoreAssetHandler::new(asset_repo.clone()));
let list_trash = Arc::new(ListTrashHandler::new(asset_repo.clone()));
let list_duplicates = Arc::new(ListDuplicatesHandler::new(duplicate_repo.clone()));
let resolve_duplicate = Arc::new(ResolveDuplicateHandler::new(
duplicate_repo.clone(),
@@ -116,6 +120,8 @@ pub fn build(
read_derivative,
register_asset,
delete_asset,
restore_asset,
list_trash,
search_assets,
list_duplicates,
resolve_duplicate,

View File

@@ -4,8 +4,8 @@ use adapters_postgres::{
PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository,
};
use application::organization::{
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
};
use presentation::state::OrganizationHandlers;
@@ -18,11 +18,13 @@ pub fn build(pool: &PgPool) -> OrganizationHandlers {
let get_album = Arc::new(GetAlbumHandler::new(album_repo.clone()));
let list_albums = Arc::new(ListAlbumsHandler::new(album_repo.clone()));
let update_album = Arc::new(UpdateAlbumHandler::new(album_repo.clone()));
let delete_album = Arc::new(DeleteAlbumHandler::new(album_repo.clone()));
let manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo));
OrganizationHandlers {
create_album,
delete_album,
get_album,
list_albums,
manage_album_entries,

View File

@@ -27,6 +27,8 @@ pub struct Asset {
pub is_processed: bool,
pub owner_user_id: SystemId,
pub created_at: DateTimeStamp,
pub deleted_at: Option<DateTimeStamp>,
pub deleted_by: Option<SystemId>,
}
impl Asset {
@@ -46,12 +48,28 @@ impl Asset {
is_processed: false,
owner_user_id: owner,
created_at: DateTimeStamp::now(),
deleted_at: None,
deleted_by: None,
}
}
pub fn mark_processed(&mut self) {
self.is_processed = true;
}
pub fn is_deleted(&self) -> bool {
self.deleted_at.is_some()
}
pub fn trash(&mut self, by: SystemId) {
self.deleted_at = Some(DateTimeStamp::now());
self.deleted_by = Some(by);
}
pub fn restore(&mut self) {
self.deleted_at = None;
self.deleted_by = None;
}
}
// --- AssetFilters ---

View File

@@ -38,6 +38,23 @@ pub trait AssetRepository: Send + Sync {
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError>;
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
async fn soft_delete(
&self,
id: &SystemId,
deleted_by: &SystemId,
) -> Result<(), DomainError>;
async fn restore(&self, id: &SystemId) -> Result<(), DomainError>;
async fn find_trashed_before(
&self,
cutoff: chrono::DateTime<chrono::Utc>,
) -> Result<Vec<Asset>, DomainError>;
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
async fn find_trashed_by_owner(
&self,
owner_id: &SystemId,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError>;
}
// --- AssetMetadataRepository ---

View File

@@ -5,8 +5,8 @@ use api_types::{
responses::AlbumResponse,
};
use application::organization::{
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ListAlbumsQuery, ManageAlbumEntriesCommand,
UpdateAlbumCommand,
AlbumAction, CreateAlbumCommand, DeleteAlbumCommand, GetAlbumQuery, ListAlbumsQuery,
ManageAlbumEntriesCommand, UpdateAlbumCommand,
};
use axum::{
Json,
@@ -108,6 +108,19 @@ pub async fn update_album(
Ok(Json(AlbumResponse::from_domain(&album)))
}
pub async fn delete_album(
State(state): State<AppState>,
claims: JwtClaims,
Path((album_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = DeleteAlbumCommand {
album_id: SystemId::from_uuid(album_id),
user_id: claims.user_id,
};
state.organization.delete_album.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
post, path = "/api/v1/albums/{id}/entries",
request_body = AlbumEntryRequest,

View File

@@ -14,8 +14,9 @@ use api_types::{
};
use application::{
catalog::{
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ReadAssetFileQuery,
ReadDerivativeQuery, RegisterAssetCommand, SearchAssetsQuery, UpdateMetadataCommand,
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ListTrashQuery,
ReadAssetFileQuery, ReadDerivativeQuery, RegisterAssetCommand, RestoreAssetCommand,
SearchAssetsQuery, UpdateMetadataCommand,
},
organization::TagAssetCommand,
storage::IngestAssetCommand,
@@ -473,3 +474,46 @@ pub async fn bulk_tag(
}
Ok(Json(serde_json::json!({ "tagged": tagged })))
}
pub async fn restore_asset(
State(state): State<AppState>,
claims: JwtClaims,
Path((asset_id,)): Path<(uuid::Uuid,)>,
) -> Result<StatusCode, AppError> {
let cmd = RestoreAssetCommand {
asset_id: SystemId::from_uuid(asset_id),
user_id: claims.user_id,
};
state.catalog.restore_asset.execute(cmd).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, serde::Deserialize)]
pub struct TrashParams {
pub limit: Option<u32>,
pub offset: Option<u32>,
}
pub async fn list_trash(
State(state): State<AppState>,
claims: JwtClaims,
Query(params): Query<TrashParams>,
) -> Result<Json<TimelineResponse>, AppError> {
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
let offset = params.offset.unwrap_or(0);
let query = ListTrashQuery {
owner_id: claims.user_id,
limit,
offset,
};
let result = state.catalog.list_trash.execute(query).await?;
let items = result
.assets
.iter()
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
.collect();
Ok(Json(TimelineResponse {
assets: items,
total: result.total,
}))
}

View File

@@ -25,6 +25,8 @@ pub fn routes() -> Router<AppState> {
get(assets::serve_derivative),
)
.route("/assets/{id}/tags", post(assets::tag_asset))
.route("/assets/trash", get(assets::list_trash))
.route("/assets/{id}/restore", post(assets::restore_asset))
.route("/assets/bulk-delete", post(assets::bulk_delete))
.route("/assets/bulk-tag", post(assets::bulk_tag))
.route(

View File

@@ -12,7 +12,9 @@ pub fn routes() -> Router<AppState> {
)
.route(
"/albums/{id}",
get(albums::get_album).put(albums::update_album),
get(albums::get_album)
.put(albums::update_album)
.delete(albums::delete_album),
)
.route("/albums/{id}/entries", post(albums::add_entry))
.route(

View File

@@ -4,17 +4,17 @@ use application::{
catalog::{
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
ListDuplicatesHandler, ListStacksHandler, ReadAssetFileHandler, ReadDerivativeHandler,
RegisterAssetHandler, ResolveDuplicateHandler, SearchAssetsHandler,
UpdateMetadataHandler,
ListDuplicatesHandler, ListStacksHandler, ListTrashHandler, ReadAssetFileHandler,
ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
},
identity::{
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler,
RegisterUserHandler,
},
organization::{
CreateAlbumHandler, GetAlbumHandler, ListAlbumsHandler, ManageAlbumEntriesHandler,
TagAssetHandler, UpdateAlbumHandler,
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
},
processing::{
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
@@ -58,6 +58,8 @@ pub struct CatalogHandlers {
pub read_derivative: Arc<ReadDerivativeHandler>,
pub register_asset: Arc<RegisterAssetHandler>,
pub delete_asset: Arc<DeleteAssetHandler>,
pub restore_asset: Arc<RestoreAssetHandler>,
pub list_trash: Arc<ListTrashHandler>,
pub search_assets: Arc<SearchAssetsHandler>,
pub list_duplicates: Arc<ListDuplicatesHandler>,
pub resolve_duplicate: Arc<ResolveDuplicateHandler>,
@@ -71,6 +73,7 @@ pub struct CatalogHandlers {
#[derive(Clone)]
pub struct OrganizationHandlers {
pub create_album: Arc<CreateAlbumHandler>,
pub delete_album: Arc<DeleteAlbumHandler>,
pub get_album: Arc<GetAlbumHandler>,
pub list_albums: Arc<ListAlbumsHandler>,
pub manage_album_entries: Arc<ManageAlbumEntriesHandler>,

View File

@@ -29,3 +29,4 @@ tracing = { workspace = true }
tracing-subscriber = { workspace = true }
dotenvy = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,7 +1,8 @@
use std::sync::Arc;
use application::catalog::DeleteAssetHandler;
use application::processing::{EnqueueJobHandler, ProcessNextJobHandler};
use domain::ports::JobRepository;
use domain::ports::{AssetRepository, JobRepository};
use crate::config::WorkerConfig;
use crate::factories::{
@@ -12,6 +13,9 @@ pub struct WorkerServices {
pub process_next: Arc<ProcessNextJobHandler>,
pub enqueue: Arc<EnqueueJobHandler>,
pub job_repo: Arc<dyn JobRepository>,
pub asset_repo: Arc<dyn AssetRepository>,
pub delete_handler: Arc<DeleteAssetHandler>,
pub trash_retention_days: u64,
pub event_consumer:
adapters_event_transport::EventConsumerAdapter<adapters_nats::NatsMessageSource>,
}
@@ -27,9 +31,8 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
let event_store: Arc<dyn domain::ports::EventStore> =
Arc::new(adapters_postgres::PostgresEventStore::new(pool.clone()));
let repos = Repos::new(pool);
let file_storage = Arc::new(adapters_storage::LocalFileStorage::new(
&config.storage_path,
));
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
Arc::new(adapters_storage::LocalFileStorage::new(&config.storage_path));
let sidecar_writer: Arc<dyn domain::ports::SidecarWriterPort> =
Arc::new(adapters_sidecar::XmpSidecarWriter);
@@ -47,7 +50,7 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
Arc::new(adapters_thumbnail::ImageThumbnailGenerator);
let registry = Arc::new(build_plugin_registry(
&repos,
file_storage,
file_storage.clone(),
sidecar_writer,
extractor,
thumbnail_gen,
@@ -60,7 +63,18 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
event_pub.clone(),
));
let job_repo: Arc<dyn JobRepository> = repos.job.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub));
let asset_repo: Arc<dyn AssetRepository> = repos.asset.clone();
let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub.clone()));
let sidecar_repo: Arc<dyn domain::ports::SidecarRepository> = repos.sidecar.clone();
let delete_handler = Arc::new(DeleteAssetHandler::new(
repos.asset.clone(),
repos.volume.clone(),
repos.derivative.clone(),
sidecar_repo,
file_storage,
event_pub,
));
let consumer_source = adapters_nats::NatsMessageSource::new(nats_client);
let event_consumer = adapters_event_transport::EventConsumerAdapter::new(consumer_source);
@@ -69,6 +83,9 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result<WorkerServices> {
process_next,
enqueue,
job_repo,
asset_repo,
delete_handler,
trash_retention_days: config.trash_retention_days,
event_consumer,
})
}

View File

@@ -4,6 +4,7 @@ pub struct WorkerConfig {
pub nats_url: String,
pub fallback_sweep_secs: u64,
pub storage_path: String,
pub trash_retention_days: u64,
}
impl WorkerConfig {
@@ -17,6 +18,10 @@ impl WorkerConfig {
.and_then(|v| v.parse().ok())
.unwrap_or(60),
storage_path: std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./storage".into()),
trash_retention_days: std::env::var("TRASH_RETENTION_DAYS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(30),
}
}
}

View File

@@ -54,6 +54,13 @@ async fn main() -> anyhow::Result<()> {
shutdown_rx.clone(),
));
tokio::spawn(sweep::purge_trash(
services.asset_repo.clone(),
services.delete_handler.clone(),
services.trash_retention_days,
shutdown_rx.clone(),
));
event_loop::run(services, shutdown_rx).await;
info!("worker shutdown complete");

View File

@@ -4,7 +4,9 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{error, info};
use application::catalog::DeleteAssetHandler;
use application::processing::{ProcessNextJobCommand, ProcessNextJobHandler};
use domain::ports::AssetRepository;
pub async fn run(
handler: Arc<ProcessNextJobHandler>,
@@ -35,3 +37,37 @@ pub async fn run(
}
}
}
pub async fn purge_trash(
asset_repo: Arc<dyn AssetRepository>,
delete_handler: Arc<DeleteAssetHandler>,
retention_days: u64,
mut shutdown: watch::Receiver<bool>,
) {
let interval = Duration::from_secs(3600);
info!(retention_days, "trash purge task started");
loop {
tokio::select! {
_ = shutdown.changed() => {
info!("trash purge: shutting down");
break;
}
_ = tokio::time::sleep(interval) => {}
}
let cutoff = chrono::Utc::now() - chrono::Duration::days(retention_days as i64);
match asset_repo.find_trashed_before(cutoff).await {
Ok(assets) if assets.is_empty() => {}
Ok(assets) => {
info!(count = assets.len(), "trash purge: purging expired assets");
for asset in &assets {
if let Err(e) = delete_handler.purge(&asset.asset_id).await {
error!(asset_id = %asset.asset_id, error = %e, "trash purge: failed");
}
}
}
Err(e) => {
error!(error = %e, "trash purge: failed to query trashed assets");
}
}
}
}