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:
3
crates/adapters/postgres/migrations/018_soft_delete.sql
Normal file
3
crates/adapters/postgres/migrations/018_soft_delete.sql
Normal 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;
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -29,3 +29,4 @@ tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
dotenvy = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user