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:
@@ -39,6 +39,11 @@ STORAGE_PATH=./data/media
|
||||
# ============================================================================
|
||||
# MAX_UPLOAD_BYTES=268435456
|
||||
|
||||
# ============================================================================
|
||||
# Trash (default 30 days before permanent purge)
|
||||
# ============================================================================
|
||||
# TRASH_RETENTION_DAYS=30
|
||||
|
||||
# ============================================================================
|
||||
# Logging
|
||||
# ============================================================================
|
||||
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4394,6 +4394,7 @@ dependencies = [
|
||||
"application",
|
||||
"async-nats",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"futures",
|
||||
|
||||
118
README.md
118
README.md
@@ -10,6 +10,34 @@ Self-hosted media orchestrator and gallery. Alternative to Apple Photos, Google
|
||||
- **Modular** — core works without AI/ML. Face detection, classification, smart search are optional plugins.
|
||||
- **BYOS** — bring your own storage. Local NAS, S3, GCS — the domain doesn't care.
|
||||
|
||||
## Features
|
||||
|
||||
### Photo Management
|
||||
- **Timeline** — date-grouped photo grid sorted by EXIF capture date, infinite scroll, date scrubber for fast navigation
|
||||
- **Image viewer** — fullscreen with zoom/pan/pinch (react-zoom-pan-pinch), keyboard nav, collapsible metadata sidebar (EXIF, camera, location)
|
||||
- **Albums** — create, add/remove photos, asset picker dialog
|
||||
- **Upload** — drag-drop with per-file progress, sequential upload through Next.js proxy
|
||||
- **Multi-select** — select photos to bulk add to albums or delete
|
||||
- **Multi-volume** — import photos from NAS, external drives, or cloud storage without copying
|
||||
|
||||
### Safe Deletion
|
||||
- **Read-only volumes** (NAS, archives): delete removes DB records + derivatives. Original files never touched.
|
||||
- **Writable volumes** (uploads): soft-delete to trash with configurable grace period before permanent purge.
|
||||
- **Trash** — view trashed photos, restore before purge. `TRASH_RETENTION_DAYS` (default 30).
|
||||
|
||||
### Admin
|
||||
- **Storage** — register volumes + library paths, import library (one-click scan), delete
|
||||
- **Jobs** — queue dashboard with filtering, pagination, error details, start/fail actions
|
||||
- **Plugins** — list, enable/disable toggle, create
|
||||
- **Pipelines** — list configured pipelines, create trigger-based processing chains
|
||||
- **Sidecars** — detect changes, bulk export/import, per-asset conflict resolution
|
||||
- **Duplicates** — view duplicate groups with thumbnails, resolve by picking keeper
|
||||
|
||||
### Auth
|
||||
- JWT access tokens + refresh token rotation
|
||||
- Role-based access: first registered user auto-promoted to admin
|
||||
- Admin section in sidebar, hidden for regular users
|
||||
|
||||
## Architecture
|
||||
|
||||
Hexagonal / DDD with CQRS. Dependencies point inward:
|
||||
@@ -38,59 +66,29 @@ Infrastructure (Axum, Postgres, NATS, S3)
|
||||
```
|
||||
crates/
|
||||
domain/ pure Rust — entities, value objects, ports, services
|
||||
common/ errors, events, value objects (SystemId, Checksum, Email,
|
||||
Username, MimeType, RelativePath, etc.)
|
||||
identity/ user, role, permission, group, refresh token
|
||||
storage/ volumes, library paths, ingestion, quotas
|
||||
catalog/ assets, metadata, stacks, derivatives, duplicates
|
||||
organization/ albums, tags, collections
|
||||
sharing/ share scopes, targets, links, invites
|
||||
sidecar/ sidecar records, sync config
|
||||
processing/ jobs, batches, plugins, pipelines
|
||||
|
||||
application/ CQRS commands + queries with Arc<dyn Port> injection
|
||||
identity/commands/ RegisterUser, LoginUser, RefreshToken, Logout
|
||||
identity/queries/ GetProfile
|
||||
storage/commands/ RegisterVolume, RegisterLibraryPath, IngestAsset
|
||||
storage/queries/ CheckQuota
|
||||
catalog/commands/ RegisterAsset, UpdateMetadata, CreateStack, DeleteStack,
|
||||
DeleteAsset, DetectLivePhotos, ResolveDuplicate
|
||||
catalog/queries/ GetTimeline, GetAsset, GetStack, ReadAssetFile,
|
||||
ReadDerivative, SearchAssets
|
||||
organization/ CreateAlbum, ManageAlbumEntries, TagAsset, GetAlbum
|
||||
sharing/ ShareResource, GenerateShareLink, RevokeShare, AccessSharedResource
|
||||
sidecar/ ExportSidecar, DetectChanges, Import, ResolveConflict, FullExport/Import
|
||||
processing/ EnqueueJob, StartJob, CompleteJob, FailJob, ExecutePipeline,
|
||||
ManagePlugin, ConfigurePipeline, ListJobs, BatchProgress
|
||||
testing/ in-memory repo fakes + stub ports (in_memory_repo! macro)
|
||||
|
||||
api-types/ HTTP request/response DTOs with OpenAPI derives
|
||||
adapters/
|
||||
auth/ bcrypt password hashing, JWT token issuer (configurable expiry)
|
||||
postgres/ all repository implementations, event store, migrations
|
||||
storage/ local filesystem + S3 (feature-gated)
|
||||
auth/ bcrypt + JWT
|
||||
postgres/ repos, event store, migrations
|
||||
storage/ local filesystem, volume-aware file resolver
|
||||
exif/ EXIF metadata extraction
|
||||
thumbnail/ derivative generation
|
||||
sidecar/ XMP sidecar reader/writer
|
||||
event-payload/ domain event serialization
|
||||
sidecar/ XMP reader/writer
|
||||
event-transport/ composite publisher (NATS + event store)
|
||||
nats/ NATS JetStream transport
|
||||
presentation/ axum handlers, routes, extractors, middleware, parsers
|
||||
bootstrap/ config, DI wiring, entry point
|
||||
worker/ background job runner (NATS consumer)
|
||||
presentation/ axum handlers, routes, middleware
|
||||
bootstrap/ config, DI wiring, API server entry point
|
||||
worker/ background job runner (NATS consumer, sweep, trash purge)
|
||||
|
||||
k-photos-frontend/ Next.js 16 + shadcn + TanStack Query
|
||||
app/(auth)/ login, register
|
||||
app/(app)/ timeline, albums, trash, admin pages
|
||||
components/ photo grid, image viewer, upload dialog, sidebars
|
||||
hooks/ auth, timeline, albums, upload, admin hooks
|
||||
lib/ API client, token helpers, types
|
||||
```
|
||||
|
||||
### Auth
|
||||
|
||||
- JWT access tokens (1h expiry, configurable)
|
||||
- Refresh token rotation (30d, SHA-256 hashed, stored in Postgres)
|
||||
- `POST /auth/login` — returns access + refresh tokens
|
||||
- `POST /auth/refresh` — rotates refresh token, issues new pair
|
||||
- `POST /auth/logout` — revokes all refresh tokens for user
|
||||
- `require_auth` middleware on all protected routes (defense in depth)
|
||||
- Handlers still use `JwtClaims` extractor for user_id — middleware is the safety net
|
||||
- Admin-only endpoints gated by role check (processing, storage, sidecar management)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
@@ -102,31 +100,35 @@ crates/
|
||||
| `HOST` | no | `0.0.0.0` | Bind address |
|
||||
| `PORT` | no | `8000` | Bind port |
|
||||
| `CORS_ALLOWED_ORIGINS` | no | — | Comma-separated origins |
|
||||
| `MAX_UPLOAD_BYTES` | no | `268435456` | Max upload size (256 MiB) |
|
||||
| `TRASH_RETENTION_DAYS` | no | `30` | Days before trashed assets are permanently purged |
|
||||
| `RUST_LOG` | no | `info` | Log level filter |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# run tests (no DB required)
|
||||
# prerequisites: postgres, nats-server, bun
|
||||
|
||||
# backend
|
||||
cp .env.example .env # edit DATABASE_URL, JWT_SECRET
|
||||
cargo run -p bootstrap # API server on :8000
|
||||
cargo run -p worker # background job runner
|
||||
|
||||
# frontend
|
||||
cd k-photos-frontend
|
||||
bun install
|
||||
bun run dev # Next.js on :3000 (proxies /api/v1 to :8000)
|
||||
|
||||
# tests
|
||||
cargo test --workspace
|
||||
|
||||
# format + lint
|
||||
cargo fmt --all
|
||||
cargo clippy --workspace
|
||||
```
|
||||
|
||||
206 tests cover domain entities, services, application use cases, and visibility filtering.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker build -t k-photos .
|
||||
docker run -e DATABASE_URL=... -e JWT_SECRET=... -e NATS_URL=... -p 8000:8000 k-photos
|
||||
```
|
||||
|
||||
Worker (background jobs):
|
||||
```bash
|
||||
docker run -e DATABASE_URL=... -e NATS_URL=... k-photos ./k_photos-worker
|
||||
docker compose up -d # postgres + nats
|
||||
cargo run -p bootstrap
|
||||
cargo run -p worker
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { AlbumSidebar } from "@/components/album-sidebar"
|
||||
import { AdminSidebar } from "@/components/admin-sidebar"
|
||||
import { UploadDialog } from "@/components/upload-dialog"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { CameraIcon, LogOutIcon } from "lucide-react"
|
||||
import { CameraIcon, LogOutIcon, Trash2Icon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
|
||||
@@ -53,13 +53,22 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
<AlbumSidebar />
|
||||
<AdminSidebar />
|
||||
</SidebarContent>
|
||||
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
|
||||
<LogOutIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="flex flex-col border-t">
|
||||
<Link
|
||||
href="/trash"
|
||||
className="flex items-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
Trash
|
||||
</Link>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user?.username}
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={logout}>
|
||||
<LogOutIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useTimeline, useDateSummary } from "@/hooks/use-timeline"
|
||||
import { groupByDate } from "@/lib/timeline"
|
||||
import { PhotoGrid } from "@/components/photo-grid"
|
||||
import { DateScrubber } from "@/components/date-scrubber"
|
||||
import api from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function TimelinePage() {
|
||||
const qc = useQueryClient()
|
||||
const { assets, isLoading, hasMore, loadMore, total } = useTimeline()
|
||||
const { data: dateSummary } = useDateSummary()
|
||||
const groups = useMemo(() => groupByDate(assets), [assets])
|
||||
|
||||
const handleDeleteAssets = async (ids: string[]) => {
|
||||
let deleted = 0
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await api.delete(`/assets/${id}`)
|
||||
deleted++
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
if (deleted > 0) {
|
||||
toast.success(`Deleted ${deleted} photo(s)`)
|
||||
qc.invalidateQueries({ queryKey: ["timeline"] })
|
||||
qc.invalidateQueries({ queryKey: ["date-summary"] })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -27,6 +46,7 @@ export default function TimelinePage() {
|
||||
isLoading={isLoading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => loadMore()}
|
||||
onDeleteAssets={handleDeleteAssets}
|
||||
/>
|
||||
<DateScrubber dates={dateSummary ?? []} />
|
||||
</div>
|
||||
|
||||
87
k-photos-frontend/app/(app)/trash/page.tsx
Normal file
87
k-photos-frontend/app/(app)/trash/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import api from "@/lib/api"
|
||||
import type { TimelineResponse, AssetResponse } from "@/lib/types"
|
||||
import { PhotoCard } from "@/components/photo-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { toast } from "sonner"
|
||||
import { RotateCcwIcon } from "lucide-react"
|
||||
|
||||
export default function TrashPage() {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["trash"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<TimelineResponse>("/assets/trash", {
|
||||
params: { limit: 100, offset: 0 },
|
||||
})
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: async (assetId: string) => {
|
||||
await api.post(`/assets/${assetId}/restore`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["trash"] })
|
||||
qc.invalidateQueries({ queryKey: ["timeline"] })
|
||||
qc.invalidateQueries({ queryKey: ["date-summary"] })
|
||||
},
|
||||
})
|
||||
|
||||
const assets = data?.assets ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Trash</h1>
|
||||
{data && data.total > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{data.total} items
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : assets.length === 0 ? (
|
||||
<p className="py-12 text-center text-muted-foreground">
|
||||
Trash is empty
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{assets.map((asset) => (
|
||||
<div key={asset.id} className="group relative">
|
||||
<div className="opacity-60">
|
||||
<PhotoCard asset={asset} />
|
||||
</div>
|
||||
<div className="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="h-7 w-7"
|
||||
disabled={restore.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await restore.mutateAsync(asset.id)
|
||||
toast.success("Photo restored")
|
||||
} catch {
|
||||
toast.error("Failed to restore")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcwIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useAlbums } from "@/hooks/use-albums"
|
||||
import { ImageIcon, PlusIcon } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { ImageIcon, PlusIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
export function AlbumSidebar() {
|
||||
const pathname = usePathname()
|
||||
const { albums, createAlbum } = useAlbums()
|
||||
const { albums, createAlbum, deleteAlbum } = useAlbums()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newTitle, setNewTitle] = useState("")
|
||||
|
||||
@@ -59,14 +60,31 @@ export function AlbumSidebar() {
|
||||
)}
|
||||
<SidebarMenu>
|
||||
{albums.map((album) => (
|
||||
<SidebarMenuItem key={album.id}>
|
||||
<SidebarMenuItem key={album.id} className="group/album">
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={pathname === `/albums/${album.id}`}
|
||||
>
|
||||
<Link href={`/albums/${album.id}`}>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<span>{album.title}</span>
|
||||
<span className="flex-1">{album.title}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover/album:opacity-100"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await deleteAlbum(album.id)
|
||||
toast.success("Album deleted")
|
||||
} catch {
|
||||
toast.error("Failed to delete album")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="h-3 w-3" />
|
||||
</Button>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ImageViewer } from "./image-viewer"
|
||||
import { AddToAlbumDialog } from "./add-to-album-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Spinner } from "@/components/ui/spinner"
|
||||
import { ImagePlusIcon, XIcon, CheckSquareIcon } from "lucide-react"
|
||||
import { ImagePlusIcon, XIcon, CheckSquareIcon, Trash2Icon } from "lucide-react"
|
||||
|
||||
interface PhotoGridProps {
|
||||
groups: DateGroup[]
|
||||
@@ -16,6 +16,7 @@ interface PhotoGridProps {
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
onRemoveAsset?: (assetId: string) => void
|
||||
onDeleteAssets?: (assetIds: string[]) => void
|
||||
}
|
||||
|
||||
export function PhotoGrid({
|
||||
@@ -24,6 +25,7 @@ export function PhotoGrid({
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
onRemoveAsset,
|
||||
onDeleteAssets,
|
||||
}: PhotoGridProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||
@@ -94,13 +96,26 @@ export function PhotoGrid({
|
||||
{onRemoveAsset && selectedIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
selectedIds.forEach((id) => onRemoveAsset(id))
|
||||
exitSelection()
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
Remove from Album
|
||||
</Button>
|
||||
)}
|
||||
{onDeleteAssets && selectedIds.size > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
onDeleteAssets(Array.from(selectedIds))
|
||||
exitSelection()
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="mr-1.5 h-3.5 w-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={exitSelection}>
|
||||
|
||||
@@ -36,6 +36,13 @@ export function useAlbums() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||
})
|
||||
|
||||
const deleteAlbum = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
await api.delete(`/albums/${id}`)
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["albums"] }),
|
||||
})
|
||||
|
||||
const addEntry = useMutation({
|
||||
mutationFn: async ({ albumId, assetId }: { albumId: string; assetId: string }) => {
|
||||
await api.post(`/albums/${albumId}/entries`, { asset_id: assetId })
|
||||
@@ -60,6 +67,7 @@ export function useAlbums() {
|
||||
albums: query.data ?? [],
|
||||
isLoading: query.isLoading,
|
||||
createAlbum: create.mutateAsync,
|
||||
deleteAlbum: deleteAlbum.mutateAsync,
|
||||
updateAlbum: update.mutateAsync,
|
||||
addEntry: addEntry.mutateAsync,
|
||||
removeEntry: removeEntry.mutateAsync,
|
||||
|
||||
Reference in New Issue
Block a user