From 0077caa743f26fd323ae8c279efd6d49cbbc93e1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Mon, 1 Jun 2026 01:57:53 +0200 Subject: [PATCH] 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 --- .env.example | 5 + Cargo.lock | 1 + README.md | 118 +++++++++--------- .../postgres/migrations/018_soft_delete.sql | 3 + crates/adapters/postgres/src/catalog/mod.rs | 100 +++++++++++++-- .../src/catalog/commands/delete_asset.rs | 90 +++++++++---- .../application/src/catalog/commands/mod.rs | 1 + .../src/catalog/commands/restore_asset.rs | 35 ++++++ crates/application/src/catalog/mod.rs | 2 + .../src/catalog/queries/list_trash.rs | 34 +++++ crates/application/src/catalog/queries/mod.rs | 1 + crates/application/src/catalog/visibility.rs | 20 +++ .../src/organization/commands/delete_album.rs | 31 +++++ .../src/organization/commands/mod.rs | 2 + crates/application/src/organization/mod.rs | 1 + .../application/src/testing/repositories.rs | 33 +++++ crates/bootstrap/src/services/catalog.rs | 10 +- crates/bootstrap/src/services/organization.rs | 6 +- crates/domain/src/catalog/entities.rs | 18 +++ crates/domain/src/catalog/ports.rs | 17 +++ crates/presentation/src/handlers/albums.rs | 17 ++- crates/presentation/src/handlers/assets.rs | 48 ++++++- crates/presentation/src/routes/catalog.rs | 2 + .../presentation/src/routes/organization.rs | 4 +- crates/presentation/src/state.rs | 13 +- crates/worker/Cargo.toml | 1 + crates/worker/src/bootstrap.rs | 29 ++++- crates/worker/src/config.rs | 5 + crates/worker/src/main.rs | 7 ++ crates/worker/src/sweep.rs | 36 ++++++ k-photos-frontend/app/(app)/layout.tsx | 25 ++-- k-photos-frontend/app/(app)/page.tsx | 20 +++ k-photos-frontend/app/(app)/trash/page.tsx | 87 +++++++++++++ .../components/album-sidebar.tsx | 26 +++- k-photos-frontend/components/photo-grid.tsx | 21 +++- k-photos-frontend/hooks/use-albums.ts | 8 ++ 36 files changed, 752 insertions(+), 125 deletions(-) create mode 100644 crates/adapters/postgres/migrations/018_soft_delete.sql create mode 100644 crates/application/src/catalog/commands/restore_asset.rs create mode 100644 crates/application/src/catalog/queries/list_trash.rs create mode 100644 crates/application/src/organization/commands/delete_album.rs create mode 100644 k-photos-frontend/app/(app)/trash/page.tsx diff --git a/.env.example b/.env.example index 40d05be..a43472c 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,11 @@ STORAGE_PATH=./data/media # ============================================================================ # MAX_UPLOAD_BYTES=268435456 +# ============================================================================ +# Trash (default 30 days before permanent purge) +# ============================================================================ +# TRASH_RETENTION_DAYS=30 + # ============================================================================ # Logging # ============================================================================ diff --git a/Cargo.lock b/Cargo.lock index f0565c5..5690516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4394,6 +4394,7 @@ dependencies = [ "application", "async-nats", "async-trait", + "chrono", "domain", "dotenvy", "futures", diff --git a/README.md b/README.md index d9bb982..8d5b36d 100644 --- a/README.md +++ b/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 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 diff --git a/crates/adapters/postgres/migrations/018_soft_delete.sql b/crates/adapters/postgres/migrations/018_soft_delete.sql new file mode 100644 index 0000000..0d4f964 --- /dev/null +++ b/crates/adapters/postgres/migrations/018_soft_delete.sql @@ -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; diff --git a/crates/adapters/postgres/src/catalog/mod.rs b/crates/adapters/postgres/src/catalog/mod.rs index 1ee51ff..e318d51 100644 --- a/crates/adapters/postgres/src/catalog/mod.rs +++ b/crates/adapters/postgres/src/catalog/mod.rs @@ -33,6 +33,8 @@ struct AssetRow { is_processed: bool, owner_user_id: Uuid, created_at: DateTime, + deleted_at: Option>, + deleted_by: Option, } fn asset_type_from_str(s: &str) -> AssetType { @@ -68,6 +70,8 @@ impl TryFrom 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, 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, 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, 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 { 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 { 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, + ) -> Result, 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 { + 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, 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() + } } // ────────────────────────────────────────────── diff --git a/crates/application/src/catalog/commands/delete_asset.rs b/crates/application/src/catalog/commands/delete_asset.rs index 26dcff9..0fdf8e5 100644 --- a/crates/application/src/catalog/commands/delete_asset.rs +++ b/crates/application/src/catalog/commands/delete_asset.rs @@ -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, + volume_repo: Arc, derivative_repo: Arc, sidecar_repo: Arc, file_storage: Arc, @@ -25,6 +27,7 @@ pub struct DeleteAssetHandler { impl DeleteAssetHandler { pub fn new( asset_repo: Arc, + volume_repo: Arc, derivative_repo: Arc, sidecar_repo: Arc, file_storage: Arc, @@ -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(()) + } } diff --git a/crates/application/src/catalog/commands/mod.rs b/crates/application/src/catalog/commands/mod.rs index b6e38d3..f3bd868 100644 --- a/crates/application/src/catalog/commands/mod.rs +++ b/crates/application/src/catalog/commands/mod.rs @@ -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; diff --git a/crates/application/src/catalog/commands/restore_asset.rs b/crates/application/src/catalog/commands/restore_asset.rs new file mode 100644 index 0000000..44adbb6 --- /dev/null +++ b/crates/application/src/catalog/commands/restore_asset.rs @@ -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, +} + +impl RestoreAssetHandler { + pub fn new(asset_repo: Arc) -> 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 + } +} diff --git a/crates/application/src/catalog/mod.rs b/crates/application/src/catalog/mod.rs index cce4783..43ac1e7 100644 --- a/crates/application/src/catalog/mod.rs +++ b/crates/application/src/catalog/mod.rs @@ -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, diff --git a/crates/application/src/catalog/queries/list_trash.rs b/crates/application/src/catalog/queries/list_trash.rs new file mode 100644 index 0000000..3a42e80 --- /dev/null +++ b/crates/application/src/catalog/queries/list_trash.rs @@ -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, + pub total: u64, +} + +pub struct ListTrashHandler { + asset_repo: Arc, +} + +impl ListTrashHandler { + pub fn new(asset_repo: Arc) -> Self { + Self { asset_repo } + } + + pub async fn execute(&self, query: ListTrashQuery) -> Result { + 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 }) + } +} diff --git a/crates/application/src/catalog/queries/mod.rs b/crates/application/src/catalog/queries/mod.rs index bed08f7..430ed2c 100644 --- a/crates/application/src/catalog/queries/mod.rs +++ b/crates/application/src/catalog/queries/mod.rs @@ -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; diff --git a/crates/application/src/catalog/visibility.rs b/crates/application/src/catalog/visibility.rs index aae852e..af8a04e 100644 --- a/crates/application/src/catalog/visibility.rs +++ b/crates/application/src/catalog/visibility.rs @@ -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) -> Result, DomainError> { + self.inner.find_trashed_before(cutoff).await + } + + async fn count_trashed(&self, owner_id: &SystemId) -> Result { + self.inner.count_trashed(owner_id).await + } + + async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result, DomainError> { + self.inner.find_trashed_by_owner(owner_id, limit, offset).await + } } #[cfg(test)] diff --git a/crates/application/src/organization/commands/delete_album.rs b/crates/application/src/organization/commands/delete_album.rs new file mode 100644 index 0000000..d5f30e5 --- /dev/null +++ b/crates/application/src/organization/commands/delete_album.rs @@ -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, +} + +impl DeleteAlbumHandler { + pub fn new(repo: Arc) -> 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 + } +} diff --git a/crates/application/src/organization/commands/mod.rs b/crates/application/src/organization/commands/mod.rs index c37ff50..1eee26c 100644 --- a/crates/application/src/organization/commands/mod.rs +++ b/crates/application/src/organization/commands/mod.rs @@ -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}; diff --git a/crates/application/src/organization/mod.rs b/crates/application/src/organization/mod.rs index 3e535e3..db722ae 100644 --- a/crates/application/src/organization/mod.rs +++ b/crates/application/src/organization/mod.rs @@ -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}; diff --git a/crates/application/src/testing/repositories.rs b/crates/application/src/testing/repositories.rs index 48d50db..ff393a1 100644 --- a/crates/application/src/testing/repositories.rs +++ b/crates/application/src/testing/repositories.rs @@ -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) -> Result, 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 { + 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, 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); diff --git a/crates/bootstrap/src/services/catalog.rs b/crates/bootstrap/src/services/catalog.rs index d93bdea..5826f74 100644 --- a/crates/bootstrap/src/services/catalog.rs +++ b/crates/bootstrap/src/services/catalog.rs @@ -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, diff --git a/crates/bootstrap/src/services/organization.rs b/crates/bootstrap/src/services/organization.rs index b373f01..8572a38 100644 --- a/crates/bootstrap/src/services/organization.rs +++ b/crates/bootstrap/src/services/organization.rs @@ -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, diff --git a/crates/domain/src/catalog/entities.rs b/crates/domain/src/catalog/entities.rs index fee8719..5ad11ad 100644 --- a/crates/domain/src/catalog/entities.rs +++ b/crates/domain/src/catalog/entities.rs @@ -27,6 +27,8 @@ pub struct Asset { pub is_processed: bool, pub owner_user_id: SystemId, pub created_at: DateTimeStamp, + pub deleted_at: Option, + pub deleted_by: Option, } 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 --- diff --git a/crates/domain/src/catalog/ports.rs b/crates/domain/src/catalog/ports.rs index 3888ac8..d105138 100644 --- a/crates/domain/src/catalog/ports.rs +++ b/crates/domain/src/catalog/ports.rs @@ -38,6 +38,23 @@ pub trait AssetRepository: Send + Sync { ) -> Result, 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, + ) -> Result, DomainError>; + async fn count_trashed(&self, owner_id: &SystemId) -> Result; + async fn find_trashed_by_owner( + &self, + owner_id: &SystemId, + limit: u32, + offset: u32, + ) -> Result, DomainError>; } // --- AssetMetadataRepository --- diff --git a/crates/presentation/src/handlers/albums.rs b/crates/presentation/src/handlers/albums.rs index f0077b1..792e436 100644 --- a/crates/presentation/src/handlers/albums.rs +++ b/crates/presentation/src/handlers/albums.rs @@ -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, + claims: JwtClaims, + Path((album_id,)): Path<(uuid::Uuid,)>, +) -> Result { + 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, diff --git a/crates/presentation/src/handlers/assets.rs b/crates/presentation/src/handlers/assets.rs index 90c9f5a..76f17cd 100644 --- a/crates/presentation/src/handlers/assets.rs +++ b/crates/presentation/src/handlers/assets.rs @@ -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, + claims: JwtClaims, + Path((asset_id,)): Path<(uuid::Uuid,)>, +) -> Result { + 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, + pub offset: Option, +} + +pub async fn list_trash( + State(state): State, + claims: JwtClaims, + Query(params): Query, +) -> Result, 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, + })) +} diff --git a/crates/presentation/src/routes/catalog.rs b/crates/presentation/src/routes/catalog.rs index 93717d4..41bcf20 100644 --- a/crates/presentation/src/routes/catalog.rs +++ b/crates/presentation/src/routes/catalog.rs @@ -25,6 +25,8 @@ pub fn routes() -> Router { 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( diff --git a/crates/presentation/src/routes/organization.rs b/crates/presentation/src/routes/organization.rs index 80690f4..e2e51ae 100644 --- a/crates/presentation/src/routes/organization.rs +++ b/crates/presentation/src/routes/organization.rs @@ -12,7 +12,9 @@ pub fn routes() -> Router { ) .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( diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index 880b9cf..4732e39 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -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, pub register_asset: Arc, pub delete_asset: Arc, + pub restore_asset: Arc, + pub list_trash: Arc, pub search_assets: Arc, pub list_duplicates: Arc, pub resolve_duplicate: Arc, @@ -71,6 +73,7 @@ pub struct CatalogHandlers { #[derive(Clone)] pub struct OrganizationHandlers { pub create_album: Arc, + pub delete_album: Arc, pub get_album: Arc, pub list_albums: Arc, pub manage_album_entries: Arc, diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index c1cd974..292e59b 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -29,3 +29,4 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } dotenvy = { workspace = true } async-trait = { workspace = true } +chrono = { workspace = true } diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index dbb740e..34bd419 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -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, pub enqueue: Arc, pub job_repo: Arc, + pub asset_repo: Arc, + pub delete_handler: Arc, + pub trash_retention_days: u64, pub event_consumer: adapters_event_transport::EventConsumerAdapter, } @@ -27,9 +31,8 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result { let event_store: Arc = 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 = + Arc::new(adapters_storage::LocalFileStorage::new(&config.storage_path)); let sidecar_writer: Arc = Arc::new(adapters_sidecar::XmpSidecarWriter); @@ -47,7 +50,7 @@ pub async fn build(config: &WorkerConfig) -> anyhow::Result { 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 { event_pub.clone(), )); let job_repo: Arc = repos.job.clone(); - let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub)); + let asset_repo: Arc = repos.asset.clone(); + let enqueue = Arc::new(build_enqueue_handler(&repos, event_pub.clone())); + + let sidecar_repo: Arc = 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 { process_next, enqueue, job_repo, + asset_repo, + delete_handler, + trash_retention_days: config.trash_retention_days, event_consumer, }) } diff --git a/crates/worker/src/config.rs b/crates/worker/src/config.rs index b45468d..f3d1175 100644 --- a/crates/worker/src/config.rs +++ b/crates/worker/src/config.rs @@ -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), } } } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 05cb24f..1b7e504 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -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"); diff --git a/crates/worker/src/sweep.rs b/crates/worker/src/sweep.rs index f295eb1..5e6433a 100644 --- a/crates/worker/src/sweep.rs +++ b/crates/worker/src/sweep.rs @@ -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, @@ -35,3 +37,37 @@ pub async fn run( } } } + +pub async fn purge_trash( + asset_repo: Arc, + delete_handler: Arc, + retention_days: u64, + mut shutdown: watch::Receiver, +) { + 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"); + } + } + } +} diff --git a/k-photos-frontend/app/(app)/layout.tsx b/k-photos-frontend/app/(app)/layout.tsx index ab788b4..9b4a1cc 100644 --- a/k-photos-frontend/app/(app)/layout.tsx +++ b/k-photos-frontend/app/(app)/layout.tsx @@ -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 }) { -
- - {user?.username} - - +
+ + + Trash + +
+ + {user?.username} + + +
diff --git a/k-photos-frontend/app/(app)/page.tsx b/k-photos-frontend/app/(app)/page.tsx index 85e670a..d455171 100644 --- a/k-photos-frontend/app/(app)/page.tsx +++ b/k-photos-frontend/app/(app)/page.tsx @@ -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 (
@@ -27,6 +46,7 @@ export default function TimelinePage() { isLoading={isLoading} hasMore={hasMore} onLoadMore={() => loadMore()} + onDeleteAssets={handleDeleteAssets} />
diff --git a/k-photos-frontend/app/(app)/trash/page.tsx b/k-photos-frontend/app/(app)/trash/page.tsx new file mode 100644 index 0000000..890dbc9 --- /dev/null +++ b/k-photos-frontend/app/(app)/trash/page.tsx @@ -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("/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 ( +
+
+

Trash

+ {data && data.total > 0 && ( + + {data.total} items + + )} +
+ + {isLoading ? ( + + ) : assets.length === 0 ? ( +

+ Trash is empty +

+ ) : ( +
+ {assets.map((asset) => ( +
+
+ +
+
+ +
+
+ ))} +
+ )} +
+ ) +} diff --git a/k-photos-frontend/components/album-sidebar.tsx b/k-photos-frontend/components/album-sidebar.tsx index 355816f..384866f 100644 --- a/k-photos-frontend/components/album-sidebar.tsx +++ b/k-photos-frontend/components/album-sidebar.tsx @@ -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() { )} {albums.map((album) => ( - + - {album.title} + {album.title} + diff --git a/k-photos-frontend/components/photo-grid.tsx b/k-photos-frontend/components/photo-grid.tsx index e3d6e2a..a40bdac 100644 --- a/k-photos-frontend/components/photo-grid.tsx +++ b/k-photos-frontend/components/photo-grid.tsx @@ -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(null) const [selectedIndex, setSelectedIndex] = useState(null) @@ -94,13 +96,26 @@ export function PhotoGrid({ {onRemoveAsset && selectedIds.size > 0 && ( + )} + {onDeleteAssets && selectedIds.size > 0 && ( + )}