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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user