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

@@ -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()
}
}
// ──────────────────────────────────────────────