Compare commits

...

30 Commits

Author SHA1 Message Date
c251a5c41f perf: concurrent worker with claim/execute split + graceful shutdown
- JobRepository::claim_next() — atomic SELECT FOR UPDATE SKIP LOCKED +
  UPDATE status=processing in one query, no duplicate claims
- ExecutePipelineHandler skips start() for already-claimed jobs
- Sweep spawns N concurrent tasks via JoinSet, claims are fast+sequential,
  execution is slow+concurrent
- Graceful shutdown: stop claiming, await all in-flight JoinSet tasks
- WORKER_CONCURRENCY env (default: CPU cores)
- DB_MAX_CONNECTIONS env (default: 20, was hardcoded 10)
- VolumeFileResolver impl for InMemoryFileStorage (test fix)
2026-06-01 02:14:44 +02:00
0077caa743 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
2026-06-01 01:57:53 +02:00
957737ac9b feat: frontend MVP — auth, timeline, upload, albums, admin, image viewer
Backend:
- user roles (DB + JWT + first-user-is-admin)
- volume-aware file resolver (multi-volume asset serving)
- directory scanner uses volume URI directly
- date-summary endpoint (capture date from EXIF)
- timeline ordered by capture date
- list endpoints: volumes, plugins, pipelines, library paths
- delete endpoints: volumes, library paths
- configurable upload body limit (MAX_UPLOAD_BYTES)

Frontend:
- auth: login/register, token refresh, role-based admin gate
- timeline: date-grouped grid, infinite scroll, date scrubber
- image viewer: fullscreen zoom/pan/pinch, metadata sidebar
- upload: drag-drop, sequential upload, progress tracking
- albums: create, add/remove photos, asset picker dialog
- admin: storage (import library), jobs (pagination, error details),
  plugins (list + toggle), pipelines, sidecars, duplicates
- multi-select mode with add-to-album action
- TanStack Query for all data fetching
2026-06-01 01:35:43 +02:00
49f77a78b9 refactor: split routes.rs into per-context modules
routes/auth.rs — public (register, login, refresh) + protected (me, logout)
routes/catalog.rs — assets, stacks, duplicates
routes/organization.rs — albums
routes/sharing.rs — public (access_by_token) + protected (share, link, revoke)
routes/storage.rs — volumes, library paths, quota
routes/sidecar.rs — export, import, detect, resolve, full ops
routes/processing.rs — jobs, batches, plugins, pipelines
routes/mod.rs — merges all, applies require_auth to protected group
2026-05-31 23:13:07 +02:00
6140ecd3ba refactor: split worker into bootstrap, event_loop, sweep modules
main.rs: 234 → 55 lines, just config + spawn + await.
bootstrap.rs: DI wiring, returns WorkerServices struct.
event_loop.rs: event dispatch with extracted helpers
  (enqueue_cmd, handle_job_completed, drain_one).
sweep.rs: fallback job drainer on interval.
2026-05-31 23:09:21 +02:00
7b5bb66b37 feat: frontend-ready backend — pagination, auto-derivatives, list endpoints, bulk ops, OpenAPI
Pagination: count_by_owner + count_search on AssetRepository,
timeline/search return real total count (not page len).

Auto-derivatives: worker enqueues GenerateDerivative when
ExtractMetadata job completes, closing the upload→thumbnail gap.

List endpoints: GET /albums, GET /stacks with user scoping.
ListAlbumsHandler, ListStacksHandler, find_by_owner on AssetStackRepository.

Tag filtering: tag_name field on AssetFilters, JOIN asset_tags+tags
in postgres search/count queries.

Bulk operations: POST /assets/bulk-delete, POST /assets/bulk-tag.

Album update: PUT /albums/{id} with UpdateAlbumHandler (title, description).

OpenAPI: utoipa annotations on all 47 endpoints + all request/response
schemas registered. Scalar UI at /scalar covers full API.
2026-05-31 23:06:25 +02:00
bcaf49cc81 perf: scale fixes for 1M+ photo libraries
Indexes: share_targets.target_id, duplicate_groups.status,
GIN on stacks members + duplicate candidates JSONB,
composite (owner_user_id, created_at DESC) on assets.

N+1 elimination: batch metadata loading via find_by_assets(ids)
using WHERE asset_id = ANY($1), used in timeline + sidecar export.

Visibility: cache find_targets_for_user per request via OnceCell,
extract filter_visible helper to reduce duplication.

Streaming: FileStoragePort.open_file() returns (DataStream, u64),
LocalFileStorage uses ReaderStream instead of loading full file.
serve_file/serve_derivative use Body::from_stream().

Unbounded queries: sidecar full_export/import batched in 500-row
chunks instead of u32::MAX. find_unresolved paginated with
limit/offset. list_duplicates API accepts pagination params.
2026-05-31 22:40:25 +02:00
d879fd6437 fix: sync .env.example with actual config
Remove vars not read by code (DB_MAX/MIN_CONNECTIONS, STORAGE_BACKEND,
PRODUCTION, JWT_EXPIRY_HOURS). Add missing NATS_URL, RUST_LOG.
Fix PORT default 3000->8000, CORS origin to match.
2026-05-31 22:28:36 +02:00
168f2a6a27 docs: update Dockerfile and README
Dockerfile: add missing adapter crates (exif, sidecar, thumbnail).
README: update project structure, auth section, env vars table,
test count (206), docker usage, bounded context descriptions.
2026-05-31 22:27:55 +02:00
c6f82090d2 feat: auth hardening + codebase quality sweep
Refresh tokens: RefreshToken entity, PostgresRefreshTokenRepository,
login returns refresh token, POST /auth/refresh (rotation), POST /auth/logout,
JWT expiry 24h→1h, configurable via with_expiry().

Route protection: require_auth middleware on protected routes,
public routes split (register, login, refresh, sharing/access).

Authorization: caller_id added to ReadAssetFileQuery, ReadDerivativeQuery,
GetStackQuery, DeleteStackCommand with ownership checks. Admin-only gates
on processing, storage, sidecar, duplicates handlers.

Quality fixes: visibility filtering bypass in search(), unwrap panics in
date parsing, DRY auth header parsing, centralized parsers module,
email validation via email_address crate, value objects (Username, MimeType,
RelativePath), domain events (UserCreated, UserDeleted, AlbumCreated,
TagCreated, DuplicateDetected), postgres error mapping for constraint
violations, OptionExt::or_not_found helper, in_memory_repo! macro,
GetStackQuery moved to queries, album add_entry 200→201.
2026-05-31 22:26:02 +02:00
84fb410316 fe init 2026-05-31 21:32:28 +02:00
95916cedde feat: directory scanner plugin — walk library paths, auto-register assets
- DirectoryScannerPlugin: recursive directory walk via FileStoragePort
- Computes SHA256 checksums, classifies media by extension
- Registers each file via RegisterAssetHandler (triggers AssetIngested → extract_metadata pipeline)
- Reads library_path_id from job payload, looks up volume + path
- Seeded plugin + scan_directory pipeline
- Trigger via POST /jobs with { job_type: "ScanDirectory", payload: { library_path_id: "..." } }
2026-05-31 21:18:23 +02:00
ef64e86439 feat: serve derivative files via GET /assets/{id}/derivatives/{profile}
- ReadDerivativeHandler queries DerivativeRepository + FileStoragePort
- Profile URL param: thumbnail, thumbnail_large, web_optimized, video_sd
- Immutable cache headers (derivatives don't change once generated)
- Wired into bootstrap catalog service builder
2026-05-31 21:10:58 +02:00
f85c0cb246 feat: real XMP sidecar adapter, replaces LogSidecarWriter stubs
- adapters-sidecar: XmpSidecarWriter using xmp_toolkit
- Writes StructuredData → XMP with EXIF/DC/XMP namespace routing
- Reads XMP back to StructuredData
- Wired into bootstrap + worker, deleted both LogSidecarWriter stubs
2026-05-31 21:05:46 +02:00
d379f3d3c8 refactor: code smell fixes — tests, events, naming
- Tests for ExecutePipelineHandler (happy path, fallback, disabled skip, failure retry, not found)
- Tests for ProcessNextJobHandler (empty queue, process, drain multiple)
- DerivativeGenerated domain event + event-payload mapping + event_store aggregate
- Renamed event-payload → adapters-event-payload, event-transport → adapters-event-transport
2026-05-31 21:00:50 +02:00
e11a1a828b refactor: use workspace deps for all internal crates, no relative paths 2026-05-31 20:48:09 +02:00
35d5baf7be feat: thumbnail generator plugin with configurable size/format
- ThumbnailGeneratorPort in domain (bytes + config → resized bytes)
- adapters-thumbnail: ImageThumbnailGenerator using image crate
- ThumbnailGeneratorPlugin reads width/height/format/profile from step config
- PostgresDerivativeRepository + 012_derivatives migration
- Seeded in extract_metadata pipeline as step 2 (300x300 webp)
- Standalone generate_derivative pipeline for on-demand use
2026-05-31 20:44:55 +02:00
45669ec848 feat: real EXIF extraction via adapters-exif crate
- MetadataExtractorPort in domain (bytes → StructuredData)
- adapters-exif: NomExifExtractor using nom-exif, handles EXIF + TrackInfo
- Worker's MetadataExtractorPlugin delegates to port, no longer knows nom-exif
- Filters noisy binary tags (U8Array, Undefined, Unknown)
2026-05-31 20:28:50 +02:00
d1c7243f5b feat: seed default plugins/pipelines, auto-enqueue jobs on asset ingest
- Migration seeds metadata_extractor, sidecar_sync, no_op plugins
- Pipelines: extract_metadata → metadata_extractor, sync_sidecar → sidecar_sync
- Worker reacts to AssetIngested → enqueues ExtractMetadata job
- Worker reacts to SidecarSyncRequested → enqueues SyncSidecar job
- Closes the ingest-to-processing loop end-to-end
2026-05-31 20:12:42 +02:00
b5cda3afeb feat: add VisibilityFilteredAssetRepository decorator for automatic access control on asset queries 2026-05-31 19:06:49 +02:00
0b2237860e refactor: introduce IngestTransaction port to reduce IngestAssetHandler from 7 to 4 ports 2026-05-31 18:44:51 +02:00
aa09aec66b feat: event store — persist domain events to Postgres event_log table via composite publisher 2026-05-31 18:36:10 +02:00
d022cb9068 feat: event-driven job dispatch via NATS subscription with 60s fallback sweep 2026-05-31 18:31:53 +02:00
5a4eb1e4f8 refactor: split bootstrap factory into per-context service builders 2026-05-31 18:28:57 +02:00
c16c9d4581 refactor: extract pg_repo macro and MapDomainError trait to reduce postgres adapter boilerplate 2026-05-31 18:24:16 +02:00
2fe0a4c245 dockerfile 2026-05-31 17:51:39 +02:00
838ed9a3f8 feat: wire NATS event publisher into bootstrap + worker
- Both binaries connect to NATS on startup, ensure JetStream stream
- EventPublisherAdapter<NatsTransport> replaces LogEventPublisher
- nats_url config with default nats://localhost:4222
- Deleted bootstrap's LogEventPublisher (no longer needed)
2026-05-31 11:53:51 +02:00
0e9911ebfc feat: event infrastructure — payload, transport, NATS adapter
- EventPublisher now takes &DomainEvent (11 call sites + 3 impls updated)
- EventEnvelope + EventConsumer port in domain
- event-payload: serializable DomainEvent mirror with subject routing
- event-transport: generic Transport/MessageSource traits, publisher/consumer adapters
- adapters-nats: JetStream publish + durable pull consumer
2026-05-31 11:50:16 +02:00
dacfc3d453 feat: worker plugin system — domain ports, pipeline executor, built-in plugins
- PluginExecutor + PluginRegistry ports in domain
- ExecutePipelineCommand orchestrates job→pipeline→plugin steps
- ProcessNextJobCommand polls + executes next queued job
- InMemoryPluginRegistry, NoOp/MetadataExtractor/SidecarSync plugins
- Worker main rewritten with poll loop, factories module for DI
- Deleted template job/runner/jobs remnants
2026-05-31 11:35:05 +02:00
6c88ac344c refactor: extract inline tests to separate files in auth + storage adapters 2026-05-31 11:16:18 +02:00
318 changed files with 23679 additions and 1615 deletions

View File

@@ -7,36 +7,49 @@
# Server # Server
# ============================================================================ # ============================================================================
HOST=0.0.0.0 HOST=0.0.0.0
PORT=3000 PORT=8000
# ============================================================================ # ============================================================================
# Database # Database
# ============================================================================ # ============================================================================
DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos
DB_MAX_CONNECTIONS=5
DB_MIN_CONNECTIONS=1
# ============================================================================ # ============================================================================
# JWT # JWT
# ============================================================================ # ============================================================================
JWT_SECRET=change-me-in-production-at-least-32-characters JWT_SECRET=change-me-in-production-at-least-32-characters
# Token lifetime in hours (default: 24) # ============================================================================
JWT_EXPIRY_HOURS=24 # NATS
# ============================================================================
NATS_URL=nats://localhost:4222
# ============================================================================ # ============================================================================
# CORS # CORS
# ============================================================================ # ============================================================================
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 CORS_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:5173
# ============================================================================ # ============================================================================
# Storage # Storage
# ============================================================================ # ============================================================================
STORAGE_BACKEND=local
STORAGE_PATH=./data/media STORAGE_PATH=./data/media
# ============================================================================ # ============================================================================
# Production Mode # Uploads (default 256 MiB)
# ============================================================================ # ============================================================================
PRODUCTION=false # MAX_UPLOAD_BYTES=268435456
# ============================================================================
# Worker concurrency (default: number of CPU cores)
# ============================================================================
# WORKER_CONCURRENCY=8
# ============================================================================
# Trash (default 30 days before permanent purge)
# ============================================================================
# TRASH_RETENTION_DAYS=30
# ============================================================================
# Logging
# ============================================================================
RUST_LOG=info

1387
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,12 @@ members = [
"crates/adapters/postgres", "crates/adapters/postgres",
"crates/adapters/auth", "crates/adapters/auth",
"crates/adapters/storage", "crates/adapters/storage",
"crates/adapters/event-payload",
"crates/adapters/event-transport",
"crates/adapters/nats",
"crates/adapters/exif",
"crates/adapters/thumbnail",
"crates/adapters/sidecar",
"crates/presentation", "crates/presentation",
"crates/bootstrap", "crates/bootstrap",
"crates/worker", "crates/worker",
@@ -14,7 +20,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
async-trait = "0.1" async-trait = "0.1"
futures = "0.3" futures = "0.3"
bytes = "1.0" bytes = "1.0"
@@ -22,9 +28,11 @@ anyhow = "1.0"
thiserror = "2.0" thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
email_address = "0.2"
sha2 = "0.10" sha2 = "0.10"
uuid = { version = "1.0", features = ["v4", "serde"] } uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
tokio-util = { version = "0.7", features = ["io"] }
dotenvy = "0.15" dotenvy = "0.15"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
@@ -40,4 +48,13 @@ application = { path = "crates/application" }
api-types = { path = "crates/api-types" } api-types = { path = "crates/api-types" }
adapters-auth = { path = "crates/adapters/auth" } adapters-auth = { path = "crates/adapters/auth" }
adapters-storage = { path = "crates/adapters/storage" } adapters-storage = { path = "crates/adapters/storage" }
adapters-event-payload = { path = "crates/adapters/event-payload" }
adapters-event-transport = { path = "crates/adapters/event-transport" }
adapters-nats = { path = "crates/adapters/nats" }
adapters-exif = { path = "crates/adapters/exif" }
adapters-thumbnail = { path = "crates/adapters/thumbnail" }
adapters-sidecar = { path = "crates/adapters/sidecar" }
adapters-postgres = { path = "crates/adapters/postgres" }
async-nats = "0.48"
async-stream = "0.3"
presentation = { path = "crates/presentation" } presentation = { path = "crates/presentation" }

58
Dockerfile Normal file
View File

@@ -0,0 +1,58 @@
# ----- build -----
FROM rust:slim-bookworm AS builder
WORKDIR /build
# Cache dependency compilation separately from source
COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
COPY crates/adapters/exif/Cargo.toml crates/adapters/exif/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
COPY crates/adapters/sidecar/Cargo.toml crates/adapters/sidecar/Cargo.toml
COPY crates/adapters/storage/Cargo.toml crates/adapters/storage/Cargo.toml
COPY crates/adapters/thumbnail/Cargo.toml crates/adapters/thumbnail/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps without real source
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN cargo fetch
# Now copy real source and build
COPY crates ./crates
RUN cargo build --release -p bootstrap -p worker --features storage/s3
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
wget \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/k_photos ./k_photos
COPY --from=builder /build/target/release/k_photos-worker ./k_photos-worker
EXPOSE 8000
ENV RUST_LOG=info
CMD ["./k_photos"]

121
README.md
View File

@@ -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. - **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. - **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 ## Architecture
Hexagonal / DDD with CQRS. Dependencies point inward: Hexagonal / DDD with CQRS. Dependencies point inward:
@@ -25,64 +53,83 @@ Infrastructure (Axum, Postgres, NATS, S3)
| Context | Purpose | | Context | Purpose |
|---|---| |---|---|
| **Identity** | Users, roles, RBAC permissions, groups | | **Identity** | Users, roles, RBAC permissions, groups, refresh tokens |
| **Storage** | Volumes, library paths, ingestion, quotas, BYOS | | **Storage** | Volumes, library paths, ingestion, quotas, BYOS |
| **Catalog** | Assets, metadata layers, stacks, derivatives, duplicates | | **Catalog** | Assets, metadata layers, stacks, derivatives, duplicates, visibility filtering |
| **Organization** | Albums, tags, collections (smart albums) | | **Organization** | Albums, tags, collections (smart albums) |
| **Sharing** | Share scopes, targets, links, invite codes, visibility filters | | **Sharing** | Share scopes, targets, links, invite codes, visibility filters |
| **Sidecar** | XMP/JSON export, sync state, conflict resolution | | **Sidecar** | XMP/JSON export, sync state, conflict resolution |
| **Processing** | Jobs, batches, plugins, pipelines | | **Processing** | Jobs, batches, plugins, pipelines, directory scanner |
### Project Structure ### Project Structure
``` ```
crates/ crates/
domain/ pure Rust — entities, value objects, ports, services domain/ pure Rust — entities, value objects, ports, services
common/ errors, events, value objects (SystemId, Checksum, etc.)
identity/ user, role, permission, group
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 application/ CQRS commands + queries with Arc<dyn Port> injection
identity/commands/ RegisterUser, LoginUser
identity/queries/ GetProfile
storage/commands/ RegisterVolume, RegisterLibraryPath, IngestAsset
storage/queries/ CheckQuota
catalog/commands/ RegisterAsset, UpdateMetadata
catalog/queries/ GetTimeline, GetAsset
organization/ CreateAlbum, ManageAlbumEntries, TagAsset, GetAlbum
sharing/ ShareResource, GenerateShareLink, RevokeShare, AccessSharedResource
sidecar/ ExportSidecar, DetectChanges, Import, ResolveConflict, FullExport/Import
processing/ EnqueueJob, StartJob, CompleteJob, FailJob, ManagePlugin, ConfigurePipeline
testing/ in-memory repo fakes + stub ports
api-types/ HTTP request/response DTOs with OpenAPI derives api-types/ HTTP request/response DTOs with OpenAPI derives
adapters/ postgres, auth (bcrypt, JWT), object storage adapters/
presentation/ axum handlers, routes, extractors auth/ bcrypt + JWT
bootstrap/ config, DI wiring, entry point postgres/ repos, event store, migrations
worker/ background job runner storage/ local filesystem, volume-aware file resolver
exif/ EXIF metadata extraction
thumbnail/ derivative generation
sidecar/ XMP reader/writer
event-transport/ composite publisher (NATS + event store)
nats/ NATS JetStream transport
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
``` ```
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `DATABASE_URL` | yes | — | Postgres connection string |
| `JWT_SECRET` | yes | — | HMAC secret for JWT signing |
| `NATS_URL` | no | `nats://localhost:4222` | NATS server URL |
| `STORAGE_PATH` | no | `./data/media` | Local file storage root |
| `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 ## Development
```bash ```bash
# run tests (no DB required) # prerequisites: postgres, nats-server, bun
cargo test -p domain -p application
# format + lint # backend
cargo fmt --all cp .env.example .env # edit DATABASE_URL, JWT_SECRET
cargo clippy -p domain -p application 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
``` ```
148 tests cover all domain entities, services, and application use cases. ## Docker
## Status ```bash
docker compose up -d # postgres + nats
Domain and application layers complete. Next: adapters (Postgres, NATS, filesystem) and presentation layer. cargo run -p bootstrap
cargo run -p worker
```
## License ## License

View File

@@ -19,10 +19,14 @@ pub struct JwtTokenIssuer {
impl JwtTokenIssuer { impl JwtTokenIssuer {
pub fn new(secret: &str) -> Self { pub fn new(secret: &str) -> Self {
Self::with_expiry(secret, 1)
}
pub fn with_expiry(secret: &str, expiry_hours: i64) -> Self {
Self { Self {
encoding_key: EncodingKey::from_secret(secret.as_bytes()), encoding_key: EncodingKey::from_secret(secret.as_bytes()),
decoding_key: DecodingKey::from_secret(secret.as_bytes()), decoding_key: DecodingKey::from_secret(secret.as_bytes()),
expiry_hours: 24, expiry_hours,
} }
} }
} }
@@ -47,25 +51,3 @@ impl TokenIssuer for JwtTokenIssuer {
Ok((SystemId::from_uuid(uuid), data.claims.role)) Ok((SystemId::from_uuid(uuid), data.claims.role))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn issue_and_verify_roundtrip() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let user_id = SystemId::new();
let token = issuer.issue(&user_id, "user").await.unwrap();
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
assert_eq!(verified_id, user_id);
assert_eq!(verified_role, "user");
}
#[tokio::test]
async fn rejects_invalid_token() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let result = issuer.verify("not.a.valid.jwt").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}
}

View File

@@ -23,16 +23,3 @@ impl PasswordHasher for BcryptPasswordHasher {
.map_err(|e| DomainError::Internal(e.to_string())) .map_err(|e| DomainError::Internal(e.to_string()))
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn hash_and_verify_roundtrip() {
let h = BcryptPasswordHasher;
let hash = h.hash("mysecretpassword").await.unwrap();
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
}
}

View File

@@ -0,0 +1,2 @@
mod jwt;
mod password;

View File

@@ -0,0 +1,21 @@
use adapters_auth::JwtTokenIssuer;
use domain::errors::DomainError;
use domain::ports::TokenIssuer;
use domain::value_objects::SystemId;
#[tokio::test]
async fn issue_and_verify_roundtrip() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let user_id = SystemId::new();
let token = issuer.issue(&user_id, "user").await.unwrap();
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
assert_eq!(verified_id, user_id);
assert_eq!(verified_role, "user");
}
#[tokio::test]
async fn rejects_invalid_token() {
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
let result = issuer.verify("not.a.valid.jwt").await;
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
}

View File

@@ -0,0 +1,10 @@
use adapters_auth::BcryptPasswordHasher;
use domain::ports::PasswordHasher;
#[tokio::test]
async fn hash_and_verify_roundtrip() {
let h = BcryptPasswordHasher;
let hash = h.hash("mysecretpassword").await.unwrap();
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
}

View File

@@ -0,0 +1,11 @@
[package]
name = "adapters-event-payload"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -0,0 +1,382 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::SystemId};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
AssetIngested {
asset_id: String,
owner_user_id: String,
timestamp: String,
},
MetadataUpdated {
asset_id: String,
updated_by: String,
timestamp: String,
},
AssetDeleted {
asset_id: String,
deleted_by: String,
timestamp: String,
},
ShareCreated {
scope_id: String,
shareable_id: String,
created_by: String,
timestamp: String,
},
ShareRevoked {
scope_id: String,
revoked_by: String,
timestamp: String,
},
SidecarSyncRequested {
asset_id: String,
timestamp: String,
},
DerivativeGenerated {
asset_id: String,
derivative_id: String,
profile: String,
timestamp: String,
},
JobEnqueued {
job_id: String,
job_type: String,
timestamp: String,
},
JobCompleted {
job_id: String,
timestamp: String,
},
JobFailed {
job_id: String,
error: String,
timestamp: String,
},
UserCreated {
user_id: String,
timestamp: String,
},
UserDeleted {
user_id: String,
timestamp: String,
},
AlbumCreated {
album_id: String,
creator_id: String,
timestamp: String,
},
TagCreated {
tag_id: String,
asset_id: String,
timestamp: String,
},
DuplicateDetected {
group_id: String,
asset_ids: Vec<String>,
timestamp: String,
},
}
impl EventPayload {
pub fn subject(&self) -> &'static str {
match self {
Self::AssetIngested { .. } => "assets.ingested",
Self::MetadataUpdated { .. } => "metadata.updated",
Self::AssetDeleted { .. } => "assets.deleted",
Self::ShareCreated { .. } => "shares.created",
Self::ShareRevoked { .. } => "shares.revoked",
Self::SidecarSyncRequested { .. } => "sidecars.sync_requested",
Self::DerivativeGenerated { .. } => "derivatives.generated",
Self::JobEnqueued { .. } => "jobs.enqueued",
Self::JobCompleted { .. } => "jobs.completed",
Self::JobFailed { .. } => "jobs.failed",
Self::UserCreated { .. } => "users.created",
Self::UserDeleted { .. } => "users.deleted",
Self::AlbumCreated { .. } => "albums.created",
Self::TagCreated { .. } => "tags.created",
Self::DuplicateDetected { .. } => "duplicates.detected",
}
}
}
impl From<&DomainEvent> for EventPayload {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::AssetIngested {
asset_id,
owner_user_id,
timestamp,
} => Self::AssetIngested {
asset_id: asset_id.to_string(),
owner_user_id: owner_user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::MetadataUpdated {
asset_id,
updated_by,
timestamp,
} => Self::MetadataUpdated {
asset_id: asset_id.to_string(),
updated_by: updated_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::AssetDeleted {
asset_id,
deleted_by,
timestamp,
} => Self::AssetDeleted {
asset_id: asset_id.to_string(),
deleted_by: deleted_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::ShareCreated {
scope_id,
shareable_id,
created_by,
timestamp,
} => Self::ShareCreated {
scope_id: scope_id.to_string(),
shareable_id: shareable_id.to_string(),
created_by: created_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::ShareRevoked {
scope_id,
revoked_by,
timestamp,
} => Self::ShareRevoked {
scope_id: scope_id.to_string(),
revoked_by: revoked_by.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::SidecarSyncRequested {
asset_id,
timestamp,
} => Self::SidecarSyncRequested {
asset_id: asset_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::DerivativeGenerated {
asset_id,
derivative_id,
profile,
timestamp,
} => Self::DerivativeGenerated {
asset_id: asset_id.to_string(),
derivative_id: derivative_id.to_string(),
profile: profile.clone(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobEnqueued {
job_id,
job_type,
timestamp,
} => Self::JobEnqueued {
job_id: job_id.to_string(),
job_type: job_type.clone(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobCompleted { job_id, timestamp } => Self::JobCompleted {
job_id: job_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::JobFailed {
job_id,
error,
timestamp,
} => Self::JobFailed {
job_id: job_id.to_string(),
error: error.clone(),
timestamp: timestamp.to_string(),
},
DomainEvent::UserCreated { user_id, timestamp } => Self::UserCreated {
user_id: user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::UserDeleted { user_id, timestamp } => Self::UserDeleted {
user_id: user_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::AlbumCreated {
album_id,
creator_id,
timestamp,
} => Self::AlbumCreated {
album_id: album_id.to_string(),
creator_id: creator_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::TagCreated {
tag_id,
asset_id,
timestamp,
} => Self::TagCreated {
tag_id: tag_id.to_string(),
asset_id: asset_id.to_string(),
timestamp: timestamp.to_string(),
},
DomainEvent::DuplicateDetected {
group_id,
asset_ids,
timestamp,
} => Self::DuplicateDetected {
group_id: group_id.to_string(),
asset_ids: asset_ids.iter().map(|id| id.to_string()).collect(),
timestamp: timestamp.to_string(),
},
}
}
}
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
}
fn parse_timestamp(s: &str) -> Result<domain::value_objects::DateTimeStamp, DomainError> {
use chrono::DateTime;
let dt = DateTime::parse_from_rfc3339(s)
.map_err(|_| DomainError::Internal(format!("invalid timestamp: {s}")))?;
Ok(domain::value_objects::DateTimeStamp::from_datetime(
dt.with_timezone(&chrono::Utc),
))
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
Ok(match p {
EventPayload::AssetIngested {
asset_id,
owner_user_id,
timestamp,
} => DomainEvent::AssetIngested {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
owner_user_id: SystemId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::MetadataUpdated {
asset_id,
updated_by,
timestamp,
} => DomainEvent::MetadataUpdated {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
updated_by: SystemId::from_uuid(parse_uuid(&updated_by, "updated_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::AssetDeleted {
asset_id,
deleted_by,
timestamp,
} => DomainEvent::AssetDeleted {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
deleted_by: SystemId::from_uuid(parse_uuid(&deleted_by, "deleted_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::ShareCreated {
scope_id,
shareable_id,
created_by,
timestamp,
} => DomainEvent::ShareCreated {
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
shareable_id: SystemId::from_uuid(parse_uuid(&shareable_id, "shareable_id")?),
created_by: SystemId::from_uuid(parse_uuid(&created_by, "created_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::ShareRevoked {
scope_id,
revoked_by,
timestamp,
} => DomainEvent::ShareRevoked {
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
revoked_by: SystemId::from_uuid(parse_uuid(&revoked_by, "revoked_by")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::SidecarSyncRequested {
asset_id,
timestamp,
} => DomainEvent::SidecarSyncRequested {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::DerivativeGenerated {
asset_id,
derivative_id,
profile,
timestamp,
} => DomainEvent::DerivativeGenerated {
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
derivative_id: SystemId::from_uuid(parse_uuid(&derivative_id, "derivative_id")?),
profile,
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobEnqueued {
job_id,
job_type,
timestamp,
} => DomainEvent::JobEnqueued {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
job_type,
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobCompleted { job_id, timestamp } => DomainEvent::JobCompleted {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::JobFailed {
job_id,
error,
timestamp,
} => DomainEvent::JobFailed {
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
error,
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::UserCreated { user_id, timestamp } => DomainEvent::UserCreated {
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::UserDeleted { user_id, timestamp } => DomainEvent::UserDeleted {
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::AlbumCreated {
album_id,
creator_id,
timestamp,
} => DomainEvent::AlbumCreated {
album_id: SystemId::from_uuid(parse_uuid(&album_id, "album_id")?),
creator_id: SystemId::from_uuid(parse_uuid(&creator_id, "creator_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::TagCreated {
tag_id,
asset_id,
timestamp,
} => DomainEvent::TagCreated {
tag_id: SystemId::from_uuid(parse_uuid(&tag_id, "tag_id")?),
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
timestamp: parse_timestamp(&timestamp)?,
},
EventPayload::DuplicateDetected {
group_id,
asset_ids,
timestamp,
} => DomainEvent::DuplicateDetected {
group_id: SystemId::from_uuid(parse_uuid(&group_id, "group_id")?),
asset_ids: asset_ids
.iter()
.map(|id| parse_uuid(id, "asset_id").map(SystemId::from_uuid))
.collect::<Result<Vec<_>, _>>()?,
timestamp: parse_timestamp(&timestamp)?,
},
})
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,89 @@
use crate::EventPayload;
use domain::{events::DomainEvent, value_objects::SystemId};
fn make_timestamp() -> domain::value_objects::DateTimeStamp {
domain::value_objects::DateTimeStamp::now()
}
#[test]
fn subject_mapping() {
let cases = vec![
(
DomainEvent::AssetIngested {
asset_id: SystemId::new(),
owner_user_id: SystemId::new(),
timestamp: make_timestamp(),
},
"assets.ingested",
),
(
DomainEvent::JobEnqueued {
job_id: SystemId::new(),
job_type: "extract_metadata".into(),
timestamp: make_timestamp(),
},
"jobs.enqueued",
),
(
DomainEvent::JobFailed {
job_id: SystemId::new(),
error: "boom".into(),
timestamp: make_timestamp(),
},
"jobs.failed",
),
];
for (event, expected_subject) in cases {
let payload = EventPayload::from(&event);
assert_eq!(payload.subject(), expected_subject);
}
}
#[test]
fn roundtrip_asset_ingested() {
let id = SystemId::new();
let owner = SystemId::new();
let event = DomainEvent::AssetIngested {
asset_id: id,
owner_user_id: owner,
timestamp: make_timestamp(),
};
let payload = EventPayload::from(&event);
let json = serde_json::to_vec(&payload).unwrap();
let back: EventPayload = serde_json::from_slice(&json).unwrap();
let restored = DomainEvent::try_from(back).unwrap();
if let DomainEvent::AssetIngested {
asset_id,
owner_user_id,
..
} = restored
{
assert_eq!(asset_id, id);
assert_eq!(owner_user_id, owner);
} else {
panic!("wrong variant");
}
}
#[test]
fn roundtrip_job_failed() {
let jid = SystemId::new();
let event = DomainEvent::JobFailed {
job_id: jid,
error: "plugin crashed".into(),
timestamp: make_timestamp(),
};
let payload = EventPayload::from(&event);
let back = DomainEvent::try_from(payload).unwrap();
if let DomainEvent::JobFailed { job_id, error, .. } = back {
assert_eq!(job_id, jid);
assert_eq!(error, "plugin crashed");
} else {
panic!("wrong variant");
}
}

View File

@@ -0,0 +1,15 @@
[package]
name = "adapters-event-transport"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
adapters-event-payload = { workspace = true }
serde_json = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
futures = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }

View File

@@ -0,0 +1,26 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, EventStore},
};
use std::sync::Arc;
pub struct CompositeEventPublisher {
primary: Arc<dyn EventPublisher>,
store: Arc<dyn EventStore>,
}
impl CompositeEventPublisher {
pub fn new(primary: Arc<dyn EventPublisher>, store: Arc<dyn EventStore>) -> Self {
Self { primary, store }
}
}
#[async_trait]
impl EventPublisher for CompositeEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
self.store.append(event).await?;
self.primary.publish(event).await
}
}

View File

@@ -0,0 +1,102 @@
pub mod composite;
pub use composite::CompositeEventPublisher;
use adapters_event_payload::EventPayload;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventEnvelope},
ports::{EventConsumer, EventPublisher},
};
use futures::stream::BoxStream;
#[async_trait]
pub trait Transport: Send + Sync {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
}
pub struct EventPublisherAdapter<T: Transport> {
transport: T,
}
impl<T: Transport> EventPublisherAdapter<T> {
pub fn new(transport: T) -> Self {
Self { transport }
}
}
#[async_trait]
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let subject = payload.subject();
let bytes =
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
tracing::debug!(subject, "publishing event");
self.transport.publish_bytes(subject, &bytes).await
}
}
pub struct RawMessage {
pub subject: String,
pub payload: Vec<u8>,
pub delivery_count: u64,
pub ack: Box<dyn Fn() + Send + Sync>,
pub nack: Box<dyn Fn() + Send + Sync>,
}
pub trait MessageSource: Send + Sync {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
}
pub struct EventConsumerAdapter<S: MessageSource> {
source: S,
}
impl<S: MessageSource> EventConsumerAdapter<S> {
pub fn new(source: S) -> Self {
Self { source }
}
}
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
use futures::StreamExt;
let stream = self.source.messages();
Box::pin(stream.filter_map(|result| async move {
match result {
Err(e) => {
tracing::warn!("transport error: {e}");
None
}
Ok(msg) => {
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
Ok(p) => p,
Err(e) => {
tracing::warn!("failed to deserialize event payload, acking: {e}");
(msg.ack)();
return None;
}
};
let event = match DomainEvent::try_from(payload) {
Ok(e) => e,
Err(e) => {
tracing::warn!("unknown event type, acking: {e}");
(msg.ack)();
return None;
}
};
Some(Ok(EventEnvelope {
event,
delivery_count: msg.delivery_count,
ack: msg.ack,
nack: msg.nack,
}))
}
}
}))
}
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,61 @@
use crate::{EventPublisherAdapter, Transport};
use async_trait::async_trait;
use domain::{
errors::DomainError, events::DomainEvent, ports::EventPublisher, value_objects::SystemId,
};
use std::sync::{Arc, Mutex};
struct RecordingTransport {
messages: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
}
#[async_trait]
impl Transport for RecordingTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
self.messages
.lock()
.unwrap()
.push((subject.to_string(), bytes.to_vec()));
Ok(())
}
}
#[tokio::test]
async fn adapter_publishes_with_correct_subject() {
let messages = Arc::new(Mutex::new(Vec::new()));
let adapter = EventPublisherAdapter::new(RecordingTransport {
messages: messages.clone(),
});
let event = DomainEvent::JobCompleted {
job_id: SystemId::new(),
timestamp: domain::value_objects::DateTimeStamp::now(),
};
adapter.publish(&event).await.unwrap();
let recorded = messages.lock().unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].0, "jobs.completed");
}
#[tokio::test]
async fn published_bytes_are_valid_json() {
let messages = Arc::new(Mutex::new(Vec::new()));
let adapter = EventPublisherAdapter::new(RecordingTransport {
messages: messages.clone(),
});
let event = DomainEvent::AssetIngested {
asset_id: SystemId::new(),
owner_user_id: SystemId::new(),
timestamp: domain::value_objects::DateTimeStamp::now(),
};
adapter.publish(&event).await.unwrap();
let recorded = messages.lock().unwrap();
let payload: adapters_event_payload::EventPayload =
serde_json::from_slice(&recorded[0].1).expect("should be valid JSON");
assert_eq!(payload.subject(), "assets.ingested");
}

View File

@@ -0,0 +1,9 @@
[package]
name = "adapters-exif"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
bytes = { workspace = true }
nom-exif = { version = "2.5", features = ["serde"] }

View File

@@ -0,0 +1,80 @@
use bytes::Bytes;
use domain::{
errors::DomainError,
ports::MetadataExtractorPort,
value_objects::{MetadataValue, StructuredData},
};
use nom_exif::{ExifIter, MediaParser, MediaSource, TrackInfo};
use std::io::Cursor;
pub struct NomExifExtractor;
impl MetadataExtractorPort for NomExifExtractor {
fn extract(&self, bytes: &Bytes) -> Result<StructuredData, DomainError> {
if bytes.is_empty() {
return Ok(StructuredData::new());
}
let ms = match MediaSource::seekable(Cursor::new(bytes.as_ref())) {
Ok(ms) => ms,
Err(_) => return Ok(StructuredData::new()),
};
let mut parser = MediaParser::new();
let mut data = StructuredData::new();
if ms.has_exif() {
let iter: ExifIter = match parser.parse(ms) {
Ok(iter) => iter,
Err(_) => return Ok(data),
};
for mut entry in iter {
let tag_name = match entry.tag() {
Some(t) => t.to_string(),
None => continue,
};
if tag_name.starts_with("Unknown(") {
continue;
}
let value = match entry.take_result() {
Ok(v) => v.to_string(),
Err(_) => continue,
};
if is_noisy_value(&value) {
continue;
}
data.insert(tag_name, MetadataValue::String(value));
}
} else {
let track_info = match parser.parse::<_, _, TrackInfo>(ms) {
Ok(info) => info,
Err(_) => return Ok(data),
};
for (key, val) in track_info {
data.insert(
format!("track:{}", key),
MetadataValue::String(val.to_string()),
);
}
}
Ok(data)
}
}
fn is_noisy_value(v: &str) -> bool {
v.starts_with("U16Array")
|| v.starts_with("U32Array")
|| v.starts_with("U8Array")
|| v.starts_with("URationalArray")
|| v.starts_with("Undefined")
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,19 @@
use crate::NomExifExtractor;
use bytes::Bytes;
use domain::ports::MetadataExtractorPort;
#[test]
fn empty_bytes_returns_empty_data() {
let extractor = NomExifExtractor;
let result = extractor.extract(&Bytes::new());
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn garbage_bytes_returns_empty_data() {
let extractor = NomExifExtractor;
let result = extractor.extract(&Bytes::from_static(b"not a real image file"));
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}

View File

@@ -0,0 +1,13 @@
[package]
name = "adapters-nats"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
adapters-event-transport = { workspace = true }
async-nats = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,212 @@
use adapters_event_transport::{MessageSource, RawMessage, Transport};
use async_nats::jetstream::{self, AckKind, stream::Config as StreamConfig};
use async_trait::async_trait;
use domain::errors::DomainError;
use futures::stream::BoxStream;
use std::sync::Arc;
const STREAM_NAME: &str = "KPHOTOS_EVENTS";
const STREAM_SUBJECT: &str = "kphotos-events.>";
const CONSUMER_NAME: &str = "worker";
const MAX_MESSAGES: i64 = 100_000;
pub const CONSUMER_MAX_DELIVER: i64 = 5;
const CONSUMER_ACK_WAIT_SECS: u64 = 30;
const ACK_TASK_TIMEOUT_SECS: u64 = 5;
fn stream_config() -> StreamConfig {
StreamConfig {
name: STREAM_NAME.to_string(),
subjects: vec![STREAM_SUBJECT.to_string()],
max_messages: MAX_MESSAGES,
..Default::default()
}
}
pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> {
let js = jetstream::new(client.clone());
if js.update_stream(stream_config()).await.is_ok() {
tracing::info!(subject = STREAM_SUBJECT, "JetStream stream updated");
return Ok(());
}
tracing::warn!(
"JetStream stream update failed (incompatible config), deleting '{STREAM_NAME}' and recreating"
);
let _ = js.delete_stream(STREAM_NAME).await;
js.create_stream(stream_config())
.await
.map(|_| ())
.map_err(|e| DomainError::Internal(format!("JetStream stream create failed: {e}")))
}
pub struct NatsTransport {
jetstream: jetstream::Context,
}
impl NatsTransport {
pub fn new(client: async_nats::Client) -> Self {
Self {
jetstream: jetstream::new(client),
}
}
}
#[async_trait]
impl Transport for NatsTransport {
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
let full_subject = format!("kphotos-events.{subject}");
self.jetstream
.publish(full_subject, bytes.to_vec().into())
.await
.map_err(|e| DomainError::Internal(e.to_string()))?
.await
.map_err(|e| DomainError::Internal(e.to_string()))?;
Ok(())
}
}
pub struct NatsMessageSource {
jetstream: jetstream::Context,
}
impl NatsMessageSource {
pub fn new(client: async_nats::Client) -> Self {
Self {
jetstream: jetstream::new(client),
}
}
}
impl MessageSource for NatsMessageSource {
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
use futures::stream;
use tokio::sync::Mutex as TokioMutex;
let js = self.jetstream.clone();
let (tx, rx) = tokio::sync::mpsc::channel::<Result<RawMessage, DomainError>>(128);
tokio::spawn(async move {
let stream = match js.get_stream(STREAM_NAME).await {
Ok(s) => s,
Err(e) => {
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
if let Ok(info) = stream.consumer_info(CONSUMER_NAME).await
&& info.config.deliver_subject.is_some()
{
tracing::info!("deleting old push consumer '{CONSUMER_NAME}', replacing with pull");
let _ = stream.delete_consumer(CONSUMER_NAME).await;
}
let consumer = match stream
.get_or_create_consumer(
CONSUMER_NAME,
jetstream::consumer::pull::Config {
durable_name: Some(CONSUMER_NAME.to_string()),
deliver_policy: jetstream::consumer::DeliverPolicy::New,
ack_policy: jetstream::consumer::AckPolicy::Explicit,
ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS),
max_deliver: CONSUMER_MAX_DELIVER,
..Default::default()
},
)
.await
{
Ok(c) => c,
Err(e) => {
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
tracing::info!("NATS pull consumer ready");
loop {
let mut messages = match consumer.messages().await {
Ok(m) => m,
Err(e) => {
tracing::error!("NATS messages() failed: {e}");
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
return;
}
};
use futures::StreamExt;
while let Some(result) = messages.next().await {
let msg = match result {
Ok(m) => m,
Err(e) => {
tracing::warn!("NATS message error: {e}");
continue;
}
};
let subject = msg.subject.to_string();
let payload = msg.payload.to_vec();
let delivery_count = msg
.info()
.map(|info| info.delivered.max(0) as u64)
.unwrap_or(1);
let msg = Arc::new(msg);
let msg_nack = Arc::clone(&msg);
let raw = RawMessage {
subject,
payload,
delivery_count,
ack: Box::new(move || {
let m = Arc::clone(&msg);
tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
m.ack(),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"),
Err(_) => tracing::warn!(
"NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
}
});
}),
nack: Box::new(move || {
let m = Arc::clone(&msg_nack);
tokio::spawn(async move {
let result = tokio::time::timeout(
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
m.ack_with(AckKind::Nak(None)),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"),
Err(_) => tracing::warn!(
"NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s"
),
}
});
}),
};
if tx.send(Ok(raw)).await.is_err() {
return;
}
}
}
});
let rx = Arc::new(TokioMutex::new(rx));
Box::pin(stream::unfold(rx, |rx| async move {
let item = rx.lock().await.recv().await?;
Some((item, rx))
}))
}
}

View File

@@ -4,11 +4,12 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
domain = { workspace = true } domain = { workspace = true }
sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] } adapters-event-payload = { workspace = true }
uuid = { workspace = true } sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] }
chrono = { workspace = true } uuid = { workspace = true }
anyhow = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } anyhow = { workspace = true }
serde = { workspace = true } async-trait = { workspace = true }
serde_json = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS event_log (
event_id BIGSERIAL PRIMARY KEY,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_event_log_aggregate ON event_log (aggregate_id);
CREATE INDEX idx_event_log_type ON event_log (event_type);
CREATE INDEX idx_event_log_occurred ON event_log (occurred_at);

View File

@@ -0,0 +1,46 @@
-- Default plugins matching worker's InMemoryPluginRegistry
INSERT INTO plugins (plugin_id, name, plugin_type, is_enabled, configuration)
VALUES
('a0000000-0000-4000-8000-000000000001', 'metadata_extractor', 'media_processor', true, '{}'),
('a0000000-0000-4000-8000-000000000002', 'sidecar_sync', 'sidecar_writer', true, '{}'),
('a0000000-0000-4000-8000-000000000003', 'no_op', 'scheduled_task', true, '{}'),
('a0000000-0000-4000-8000-000000000004', 'thumbnail_generator', 'media_processor', true, '{}'),
('a0000000-0000-4000-8000-000000000005', 'directory_scanner', 'media_processor', true, '{}')
ON CONFLICT (plugin_id) DO NOTHING;
-- Pipeline: extract_metadata → metadata_extractor, then thumbnail_generator
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
VALUES (
'b0000000-0000-4000-8000-000000000001',
'extract_metadata',
'[{"plugin_id": "a0000000-0000-4000-8000-000000000001", "step_order": 0, "configuration": {}},
{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 1, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
)
ON CONFLICT (pipeline_id) DO NOTHING;
-- Pipeline: generate_derivative (standalone, configurable per-step)
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
VALUES (
'b0000000-0000-4000-8000-000000000003',
'generate_derivative',
'[{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 0, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
)
ON CONFLICT (pipeline_id) DO NOTHING;
-- Pipeline: scan_directory → directory_scanner
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
VALUES (
'b0000000-0000-4000-8000-000000000004',
'scan_directory',
'[{"plugin_id": "a0000000-0000-4000-8000-000000000005", "step_order": 0, "configuration": {}}]'
)
ON CONFLICT (pipeline_id) DO NOTHING;
-- Pipeline: sync_sidecar → sidecar_sync
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
VALUES (
'b0000000-0000-4000-8000-000000000002',
'sync_sidecar',
'[{"plugin_id": "a0000000-0000-4000-8000-000000000002", "step_order": 0, "configuration": {}}]'
)
ON CONFLICT (pipeline_id) DO NOTHING;

View File

@@ -0,0 +1,14 @@
CREATE TABLE derivatives (
derivative_id UUID PRIMARY KEY,
parent_asset_id UUID NOT NULL REFERENCES assets(asset_id),
profile_type TEXT NOT NULL,
storage_path TEXT NOT NULL,
mime_type TEXT NOT NULL DEFAULT '',
file_size BIGINT NOT NULL DEFAULT 0,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
generation_status TEXT NOT NULL DEFAULT 'pending'
);
CREATE INDEX idx_derivatives_parent ON derivatives(parent_asset_id);
CREATE INDEX idx_derivatives_parent_profile ON derivatives(parent_asset_id, profile_type);

View File

@@ -0,0 +1,9 @@
CREATE TABLE asset_stacks (
stack_id UUID PRIMARY KEY,
stack_type TEXT NOT NULL,
primary_asset_id UUID NOT NULL REFERENCES assets(asset_id),
owner_user_id UUID NOT NULL,
members JSONB NOT NULL DEFAULT '[]'
);
CREATE INDEX idx_stacks_owner ON asset_stacks(owner_user_id);

View File

@@ -0,0 +1,10 @@
CREATE TABLE refresh_tokens (
token_id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);

View File

@@ -0,0 +1,5 @@
CREATE INDEX idx_share_targets_target ON share_targets(target_id);
CREATE INDEX idx_duplicate_groups_status ON duplicate_groups(status);
CREATE INDEX idx_stacks_members ON asset_stacks USING GIN (members);
CREATE INDEX idx_duplicate_candidates ON duplicate_groups USING GIN (candidates);
CREATE INDEX idx_assets_created ON assets(owner_user_id, created_at DESC);

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';

View File

@@ -0,0 +1 @@
UPDATE plugins SET name = 'scan_directory' WHERE plugin_id = 'a0000000-0000-4000-8000-000000000005';

View 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;

View File

@@ -1,13 +1,18 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
entities::{ entities::{
Asset, AssetMetadata, AssetType, DetectionMethod, DuplicateCandidate, DuplicateGroup, Asset, AssetFilters, AssetMetadata, AssetStack, AssetStackMember, AssetType,
DuplicateStatus, MetadataSource, SourceReference, DerivativeAsset, DerivativeProfile, DetectionMethod, DuplicateCandidate, DuplicateGroup,
DuplicateStatus, GenerationStatus, MetadataSource, SourceReference, StackMemberRole,
StackType,
}, },
errors::DomainError, errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository, DuplicateRepository}, ports::{
AssetMetadataRepository, AssetRepository, AssetStackRepository, DerivativeRepository,
DuplicateRepository,
},
value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId}, value_objects::{Checksum, DateTimeStamp, MetadataValue, StructuredData, SystemId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -28,6 +33,8 @@ struct AssetRow {
is_processed: bool, is_processed: bool,
owner_user_id: Uuid, owner_user_id: Uuid,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
deleted_at: Option<DateTime<Utc>>,
deleted_by: Option<Uuid>,
} }
fn asset_type_from_str(s: &str) -> AssetType { fn asset_type_from_str(s: &str) -> AssetType {
@@ -63,18 +70,90 @@ impl TryFrom<AssetRow> for Asset {
is_processed: r.is_processed, is_processed: r.is_processed,
owner_user_id: SystemId::from_uuid(r.owner_user_id), owner_user_id: SystemId::from_uuid(r.owner_user_id),
created_at: DateTimeStamp::from_datetime(r.created_at), 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),
}) })
} }
} }
pub struct PostgresAssetRepository { pg_repo!(PostgresAssetRepository);
pool: PgPool,
fn build_search_where(filters: &AssetFilters) -> (String, bool) {
let mut clause = String::new();
let mut idx = 2u32;
if filters.asset_type.is_some() {
clause.push_str(&format!(" AND a.asset_type = ${idx}"));
idx += 1;
}
if filters.mime_type.is_some() {
clause.push_str(&format!(" AND a.mime_type = ${idx}"));
idx += 1;
}
if filters.date_from.is_some() {
clause.push_str(&format!(" AND a.created_at >= ${idx}"));
idx += 1;
}
if filters.date_to.is_some() {
clause.push_str(&format!(" AND a.created_at <= ${idx}"));
idx += 1;
}
if filters.is_processed.is_some() {
clause.push_str(&format!(" AND a.is_processed = ${idx}"));
idx += 1;
}
let has_tag = filters.tag_name.is_some();
if has_tag {
clause.push_str(&format!(" AND t.name = ${idx}"));
}
(clause, has_tag)
} }
impl PostgresAssetRepository { fn count_filter_params(filters: &AssetFilters) -> u32 {
pub fn new(pool: PgPool) -> Self { let mut n = 0u32;
Self { pool } if filters.asset_type.is_some() {
n += 1;
} }
if filters.mime_type.is_some() {
n += 1;
}
if filters.date_from.is_some() {
n += 1;
}
if filters.date_to.is_some() {
n += 1;
}
if filters.is_processed.is_some() {
n += 1;
}
if filters.tag_name.is_some() {
n += 1;
}
n
}
fn bind_filters<'q, O>(
mut query: sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments>,
filters: &'q AssetFilters,
) -> sqlx::query::QueryAs<'q, sqlx::Postgres, O, sqlx::postgres::PgArguments> {
if let Some(ref t) = filters.asset_type {
query = query.bind(asset_type_to_str(t));
}
if let Some(ref m) = filters.mime_type {
query = query.bind(m.as_str());
}
if let Some(ref d) = filters.date_from {
query = query.bind(d.as_datetime());
}
if let Some(ref d) = filters.date_to {
query = query.bind(d.as_datetime());
}
if let Some(p) = filters.is_processed {
query = query.bind(p);
}
if let Some(ref tag) = filters.tag_name {
query = query.bind(tag.as_str());
}
query
} }
#[async_trait] #[async_trait]
@@ -82,13 +161,13 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> { async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
let row = sqlx::query_as::<_, AssetRow>( let row = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type, "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", FROM assets WHERE asset_id = $1",
) )
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -96,13 +175,13 @@ impl AssetRepository for PostgresAssetRepository {
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> { async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>( let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type, "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", FROM assets WHERE checksum = $1",
) )
.bind(checksum.as_str()) .bind(checksum.as_str())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -114,10 +193,16 @@ impl AssetRepository for PostgresAssetRepository {
offset: u32, offset: u32,
) -> Result<Vec<Asset>, DomainError> { ) -> Result<Vec<Asset>, DomainError> {
let rows = sqlx::query_as::<_, AssetRow>( let rows = sqlx::query_as::<_, AssetRow>(
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type, "SELECT a.asset_id, a.volume_id, a.relative_path, a.checksum, a.asset_type, a.mime_type,
file_size, is_processed, owner_user_id, created_at a.file_size, a.is_processed, a.owner_user_id, a.created_at, a.deleted_at, a.deleted_by
FROM assets WHERE owner_user_id = $1 FROM assets a
ORDER BY created_at DESC LEFT JOIN asset_metadata am
ON am.asset_id = a.asset_id AND am.metadata_source = 'exif_extracted'
WHERE a.owner_user_id = $1 AND a.deleted_at IS NULL
ORDER BY COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
) DESC
LIMIT $2 OFFSET $3", LIMIT $2 OFFSET $3",
) )
.bind(*owner_id.as_uuid()) .bind(*owner_id.as_uuid())
@@ -125,11 +210,126 @@ impl AssetRepository for PostgresAssetRepository {
.bind(offset as i64) .bind(offset as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
async fn search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
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, 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 {
""
},
where_clause
);
let param_count = count_filter_params(filters);
sql.push_str(&format!(
" ORDER BY a.created_at DESC LIMIT ${} OFFSET ${}",
param_count + 2,
param_count + 3
));
let mut query = sqlx::query_as::<_, AssetRow>(&sql).bind(*owner_id.as_uuid());
query = bind_filters(query, filters);
let rows = query
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter().map(TryInto::try_into).collect()
}
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 AND deleted_at IS NULL")
.bind(*owner_id.as_uuid())
.fetch_one(&self.pool)
.await
.map_pg()?;
Ok(count as u64)
}
async fn count_search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
) -> 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 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 {
""
},
where_clause
);
let mut query = sqlx::query_as::<_, (i64,)>(&sql).bind(*owner_id.as_uuid());
if let Some(ref t) = filters.asset_type {
query = query.bind(asset_type_to_str(t));
}
if let Some(ref m) = filters.mime_type {
query = query.bind(m.as_str());
}
if let Some(ref d) = filters.date_from {
query = query.bind(d.as_datetime());
}
if let Some(ref d) = filters.date_to {
query = query.bind(d.as_datetime());
}
if let Some(p) = filters.is_processed {
query = query.bind(p);
}
if let Some(ref tag) = filters.tag_name {
query = query.bind(tag.as_str());
}
let (count,) = query.fetch_one(&self.pool).await.map_pg()?;
Ok(count as u64)
}
async fn date_summary(
&self,
owner_id: &SystemId,
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
let rows: Vec<(chrono::NaiveDate, i64)> = sqlx::query_as(
"SELECT COALESCE(
(am.data->>'DateTimeOriginal')::timestamptz,
a.created_at
)::date AS day,
COUNT(*) AS cnt
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 AND a.deleted_at IS NULL
GROUP BY day ORDER BY day DESC",
)
.bind(*owner_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(|(d, c)| (d, c as u64)).collect())
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> { async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
sqlx::query( sqlx::query(
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type, "INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
@@ -157,7 +357,7 @@ impl AssetRepository for PostgresAssetRepository {
.bind(asset.created_at.as_datetime()) .bind(asset.created_at.as_datetime())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -166,9 +366,87 @@ impl AssetRepository for PostgresAssetRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) 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()
}
} }
// ────────────────────────────────────────────── // ──────────────────────────────────────────────
@@ -253,15 +531,7 @@ impl From<AssetMetadataRow> for AssetMetadata {
} }
} }
pub struct PostgresAssetMetadataRepository { pg_repo!(PostgresAssetMetadataRepository);
pool: PgPool,
}
impl PostgresAssetMetadataRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl AssetMetadataRepository for PostgresAssetMetadataRepository { impl AssetMetadataRepository for PostgresAssetMetadataRepository {
@@ -273,7 +543,24 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
.bind(*asset_id.as_uuid()) .bind(*asset_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_assets(
&self,
asset_ids: &[SystemId],
) -> Result<Vec<AssetMetadata>, DomainError> {
let uuids: Vec<Uuid> = asset_ids.iter().map(|id| *id.as_uuid()).collect();
let rows = sqlx::query_as::<_, AssetMetadataRow>(
"SELECT asset_id, metadata_source, data, updated_at
FROM asset_metadata WHERE asset_id = ANY($1)",
)
.bind(&uuids)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -291,7 +578,7 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
.bind(source_to_str(&source)) .bind(source_to_str(&source))
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -310,7 +597,7 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
.bind(metadata.updated_at.as_datetime()) .bind(metadata.updated_at.as_datetime())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -324,7 +611,7 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
.bind(source_to_str(&source)) .bind(source_to_str(&source))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -407,15 +694,7 @@ impl From<GroupRow> for DuplicateGroup {
} }
} }
pub struct PostgresDuplicateRepository { pg_repo!(PostgresDuplicateRepository);
pool: PgPool,
}
impl PostgresDuplicateRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl DuplicateRepository for PostgresDuplicateRepository { impl DuplicateRepository for PostgresDuplicateRepository {
@@ -427,19 +706,26 @@ impl DuplicateRepository for PostgresDuplicateRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError> { async fn find_unresolved(
&self,
limit: u32,
offset: u32,
) -> Result<Vec<DuplicateGroup>, DomainError> {
let rows = sqlx::query_as::<_, GroupRow>( let rows = sqlx::query_as::<_, GroupRow>(
"SELECT group_id, detection_method, status, candidates "SELECT group_id, detection_method, status, candidates
FROM duplicate_groups WHERE status = 'unresolved'", FROM duplicate_groups WHERE status = 'unresolved'
ORDER BY group_id LIMIT $1 OFFSET $2",
) )
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -452,7 +738,7 @@ impl DuplicateRepository for PostgresDuplicateRepository {
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}])) .bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -472,7 +758,317 @@ impl DuplicateRepository for PostgresDuplicateRepository {
.bind(candidates_to_json(&group.candidates)) .bind(candidates_to_json(&group.candidates))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(())
}
}
// ── DerivativeRepository ──────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct DerivativeRow {
derivative_id: Uuid,
parent_asset_id: Uuid,
profile_type: String,
storage_path: String,
mime_type: String,
file_size: i64,
width: i32,
height: i32,
generation_status: String,
}
fn profile_from_str(s: &str) -> DerivativeProfile {
match s {
"thumbnail_square" => DerivativeProfile::ThumbnailSquare,
"thumbnail_large" => DerivativeProfile::ThumbnailLarge,
"web_optimized" => DerivativeProfile::WebOptimized,
"video_sd" => DerivativeProfile::VideoSd,
_ => DerivativeProfile::ThumbnailSquare,
}
}
fn profile_to_str(p: &DerivativeProfile) -> &'static str {
match p {
DerivativeProfile::ThumbnailSquare => "thumbnail_square",
DerivativeProfile::ThumbnailLarge => "thumbnail_large",
DerivativeProfile::WebOptimized => "web_optimized",
DerivativeProfile::VideoSd => "video_sd",
}
}
fn gen_status_from_str(s: &str) -> GenerationStatus {
match s {
"pending" => GenerationStatus::Pending,
"ready" => GenerationStatus::Ready,
"failed" => GenerationStatus::Failed,
_ => GenerationStatus::Pending,
}
}
fn gen_status_to_str(s: &GenerationStatus) -> &'static str {
match s {
GenerationStatus::Pending => "pending",
GenerationStatus::Ready => "ready",
GenerationStatus::Failed => "failed",
}
}
impl From<DerivativeRow> for DerivativeAsset {
fn from(r: DerivativeRow) -> Self {
Self {
derivative_id: SystemId::from_uuid(r.derivative_id),
parent_asset_id: SystemId::from_uuid(r.parent_asset_id),
profile_type: profile_from_str(&r.profile_type),
storage_path: r.storage_path,
mime_type: r.mime_type,
file_size: r.file_size as u64,
dimensions: (r.width as u32, r.height as u32),
generation_status: gen_status_from_str(&r.generation_status),
}
}
}
pg_repo!(PostgresDerivativeRepository);
#[async_trait]
impl DerivativeRepository for PostgresDerivativeRepository {
async fn find_by_asset(
&self,
asset_id: &SystemId,
) -> Result<Vec<DerivativeAsset>, DomainError> {
let rows = sqlx::query_as::<_, DerivativeRow>(
"SELECT derivative_id, parent_asset_id, profile_type, storage_path,
mime_type, file_size, width, height, generation_status
FROM derivatives WHERE parent_asset_id = $1",
)
.bind(*asset_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_asset_and_profile(
&self,
asset_id: &SystemId,
profile: DerivativeProfile,
) -> Result<Option<DerivativeAsset>, DomainError> {
let row = sqlx::query_as::<_, DerivativeRow>(
"SELECT derivative_id, parent_asset_id, profile_type, storage_path,
mime_type, file_size, width, height, generation_status
FROM derivatives WHERE parent_asset_id = $1 AND profile_type = $2",
)
.bind(*asset_id.as_uuid())
.bind(profile_to_str(&profile))
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn save(&self, d: &DerivativeAsset) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO derivatives (derivative_id, parent_asset_id, profile_type, storage_path,
mime_type, file_size, width, height, generation_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (derivative_id) DO UPDATE SET
storage_path = EXCLUDED.storage_path,
mime_type = EXCLUDED.mime_type,
file_size = EXCLUDED.file_size,
width = EXCLUDED.width,
height = EXCLUDED.height,
generation_status = EXCLUDED.generation_status",
)
.bind(*d.derivative_id.as_uuid())
.bind(*d.parent_asset_id.as_uuid())
.bind(profile_to_str(&d.profile_type))
.bind(&d.storage_path)
.bind(&d.mime_type)
.bind(d.file_size as i64)
.bind(d.dimensions.0 as i32)
.bind(d.dimensions.1 as i32)
.bind(gen_status_to_str(&d.generation_status))
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM derivatives WHERE derivative_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
}
// ── AssetStack ──────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct StackRow {
stack_id: Uuid,
stack_type: String,
primary_asset_id: Uuid,
owner_user_id: Uuid,
members: serde_json::Value,
}
fn stack_type_from_str(s: &str) -> StackType {
match s {
"live_photo" => StackType::LivePhoto,
"format_pair" => StackType::FormatPair,
"burst_sequence" => StackType::BurstSequence,
"exposure_bracket" => StackType::ExposureBracket,
"manual_group" => StackType::ManualGroup,
_ => StackType::ManualGroup,
}
}
fn stack_type_to_str(t: &StackType) -> &'static str {
match t {
StackType::LivePhoto => "live_photo",
StackType::FormatPair => "format_pair",
StackType::BurstSequence => "burst_sequence",
StackType::ExposureBracket => "exposure_bracket",
StackType::ManualGroup => "manual_group",
}
}
fn member_role_from_str(s: &str) -> StackMemberRole {
match s {
"primary_display" => StackMemberRole::PrimaryDisplay,
"high_res_source" => StackMemberRole::HighResSource,
"motion_clip" => StackMemberRole::MotionClip,
"alternate_frame" => StackMemberRole::AlternateFrame,
_ => StackMemberRole::AlternateFrame,
}
}
fn member_role_to_str(r: &StackMemberRole) -> &'static str {
match r {
StackMemberRole::PrimaryDisplay => "primary_display",
StackMemberRole::HighResSource => "high_res_source",
StackMemberRole::MotionClip => "motion_clip",
StackMemberRole::AlternateFrame => "alternate_frame",
}
}
#[derive(serde::Serialize, serde::Deserialize)]
struct MemberJson {
asset_id: Uuid,
role: String,
sort_order: u32,
}
fn members_from_json(v: serde_json::Value) -> Vec<AssetStackMember> {
let arr: Vec<MemberJson> = serde_json::from_value(v).unwrap_or_default();
arr.into_iter()
.map(|m| AssetStackMember {
asset_id: SystemId::from_uuid(m.asset_id),
role: member_role_from_str(&m.role),
sort_order: m.sort_order,
})
.collect()
}
fn members_to_json(members: &[AssetStackMember]) -> serde_json::Value {
let arr: Vec<MemberJson> = members
.iter()
.map(|m| MemberJson {
asset_id: *m.asset_id.as_uuid(),
role: member_role_to_str(&m.role).to_string(),
sort_order: m.sort_order,
})
.collect();
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
}
impl From<StackRow> for AssetStack {
fn from(r: StackRow) -> Self {
Self {
stack_id: SystemId::from_uuid(r.stack_id),
stack_type: stack_type_from_str(&r.stack_type),
primary_asset_id: SystemId::from_uuid(r.primary_asset_id),
owner_user_id: SystemId::from_uuid(r.owner_user_id),
members: members_from_json(r.members),
}
}
}
pg_repo!(PostgresAssetStackRepository);
#[async_trait]
impl AssetStackRepository for PostgresAssetStackRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError> {
let row = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE stack_id = $1",
)
.bind(*id.as_uuid())
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
let rows = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE owner_user_id = $1",
)
.bind(*owner_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError> {
let rows = sqlx::query_as::<_, StackRow>(
"SELECT stack_id, stack_type, primary_asset_id, owner_user_id, members
FROM asset_stacks WHERE members @> $1::jsonb",
)
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO asset_stacks (stack_id, stack_type, primary_asset_id, owner_user_id, members)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (stack_id) DO UPDATE SET
stack_type = EXCLUDED.stack_type,
primary_asset_id = EXCLUDED.primary_asset_id,
members = EXCLUDED.members",
)
.bind(*stack.stack_id.as_uuid())
.bind(stack_type_to_str(&stack.stack_type))
.bind(*stack.primary_asset_id.as_uuid())
.bind(*stack.owner_user_id.as_uuid())
.bind(members_to_json(&stack.members))
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM asset_stacks WHERE stack_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,8 +1,12 @@
pub type PgPool = sqlx::PgPool; pub type PgPool = sqlx::PgPool;
pub async fn connect(url: &str) -> anyhow::Result<PgPool> { pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
let max_conn: u32 = std::env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let pool = sqlx::postgres::PgPoolOptions::new() let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10) .max_connections(max_conn)
.connect(url) .connect(url)
.await?; .await?;
Ok(pool) Ok(pool)

View File

@@ -0,0 +1,83 @@
use crate::helpers::{MapDomainError, pg_repo};
use adapters_event_payload::EventPayload;
use async_trait::async_trait;
use domain::{
errors::DomainError, events::DomainEvent, ports::EventStore, value_objects::SystemId,
};
use uuid::Uuid;
pg_repo!(PostgresEventStore);
/// Extracts the primary aggregate ID from a domain event.
fn aggregate_id(event: &DomainEvent) -> Uuid {
match event {
DomainEvent::AssetIngested { asset_id, .. }
| DomainEvent::MetadataUpdated { asset_id, .. }
| DomainEvent::AssetDeleted { asset_id, .. }
| DomainEvent::SidecarSyncRequested { asset_id, .. }
| DomainEvent::DerivativeGenerated { asset_id, .. } => *asset_id.as_uuid(),
DomainEvent::ShareCreated { scope_id, .. } | DomainEvent::ShareRevoked { scope_id, .. } => {
*scope_id.as_uuid()
}
DomainEvent::JobEnqueued { job_id, .. }
| DomainEvent::JobCompleted { job_id, .. }
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
DomainEvent::UserCreated { user_id, .. } | DomainEvent::UserDeleted { user_id, .. } => {
*user_id.as_uuid()
}
DomainEvent::AlbumCreated { album_id, .. } => *album_id.as_uuid(),
DomainEvent::TagCreated { tag_id, .. } => *tag_id.as_uuid(),
DomainEvent::DuplicateDetected { group_id, .. } => *group_id.as_uuid(),
}
}
#[async_trait]
impl EventStore for PostgresEventStore {
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
let payload = EventPayload::from(event);
let event_type = payload.subject().to_string();
let json =
serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
let agg_id = aggregate_id(event);
sqlx::query(
"INSERT INTO event_log (aggregate_id, event_type, payload, occurred_at)
VALUES ($1, $2, $3, now())",
)
.bind(agg_id)
.bind(event_type)
.bind(json)
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn query_by_aggregate(
&self,
aggregate_id: &SystemId,
) -> Result<Vec<DomainEvent>, DomainError> {
let rows: Vec<(serde_json::Value,)> = sqlx::query_as(
"SELECT payload FROM event_log WHERE aggregate_id = $1 ORDER BY event_id ASC",
)
.bind(*aggregate_id.as_uuid())
.fetch_all(&self.pool)
.await
.map_pg()?;
rows.into_iter()
.map(|(json,)| {
let payload: EventPayload = serde_json::from_value(json)
.map_err(|e| DomainError::Internal(e.to_string()))?;
DomainEvent::try_from(payload)
})
.collect()
}
}

View File

@@ -0,0 +1,45 @@
use domain::errors::DomainError;
pub trait MapDomainError<T> {
fn map_pg(self) -> Result<T, DomainError>;
}
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
fn map_pg(self) -> Result<T, DomainError> {
self.map_err(|e| match &e {
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") => {
DomainError::Conflict(
db_err
.constraint()
.map(|c| format!("Duplicate: {c}"))
.unwrap_or_else(|| "Duplicate entry".into()),
)
}
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23503") => {
DomainError::NotFound(
db_err
.constraint()
.map(|c| format!("Referenced entity not found: {c}"))
.unwrap_or_else(|| "Referenced entity not found".into()),
)
}
_ => DomainError::Internal(e.to_string()),
})
}
}
macro_rules! pg_repo {
($name:ident) => {
pub struct $name {
pool: crate::db::PgPool,
}
impl $name {
pub fn new(pool: crate::db::PgPool) -> Self {
Self { pool }
}
}
};
}
pub(crate) use pg_repo;

View File

@@ -1,10 +1,11 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
entities::RefreshToken,
errors::DomainError, errors::DomainError,
ports::UserRepository, ports::{RefreshTokenRepository, UserRepository},
value_objects::{Email, PasswordHash, SystemId}, value_objects::{DateTimeStamp, Email, PasswordHash, SystemId},
}; };
use uuid::Uuid; use uuid::Uuid;
@@ -14,6 +15,7 @@ struct UserRow {
username: String, username: String,
email: String, email: String,
password_hash: String, password_hash: String,
role: String,
created_at: DateTime<Utc>, created_at: DateTime<Utc>,
} }
@@ -25,20 +27,13 @@ impl TryFrom<UserRow> for domain::entities::User {
username: r.username, username: r.username,
email: Email::new(r.email)?, email: Email::new(r.email)?,
password_hash: PasswordHash::from_hash(r.password_hash), password_hash: PasswordHash::from_hash(r.password_hash),
role: r.role,
created_at: r.created_at, created_at: r.created_at,
}) })
} }
} }
pub struct PostgresUserRepository { pg_repo!(PostgresUserRepository);
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl UserRepository for PostgresUserRepository { impl UserRepository for PostgresUserRepository {
@@ -47,12 +42,12 @@ impl UserRepository for PostgresUserRepository {
id: &SystemId, id: &SystemId,
) -> Result<Option<domain::entities::User>, DomainError> { ) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>( let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1", "SELECT id, username, email, password_hash, role, created_at FROM users WHERE id = $1",
) )
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -62,12 +57,12 @@ impl UserRepository for PostgresUserRepository {
email: &Email, email: &Email,
) -> Result<Option<domain::entities::User>, DomainError> { ) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>( let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1", "SELECT id, username, email, password_hash, role, created_at FROM users WHERE email = $1",
) )
.bind(email.as_str()) .bind(email.as_str())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -77,34 +72,36 @@ impl UserRepository for PostgresUserRepository {
username: &str, username: &str,
) -> Result<Option<domain::entities::User>, DomainError> { ) -> Result<Option<domain::entities::User>, DomainError> {
let row = sqlx::query_as::<_, UserRow>( let row = sqlx::query_as::<_, UserRow>(
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1", "SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1",
) )
.bind(username) .bind(username)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> { async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
sqlx::query_as::<_, UserRow>( sqlx::query_as::<_, UserRow>(
"INSERT INTO users (id, username, email, password_hash, created_at) "INSERT INTO users (id, username, email, password_hash, role, created_at)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
username = EXCLUDED.username, username = EXCLUDED.username,
email = EXCLUDED.email, email = EXCLUDED.email,
password_hash = EXCLUDED.password_hash password_hash = EXCLUDED.password_hash,
RETURNING id, username, email, password_hash, created_at", role = EXCLUDED.role
RETURNING id, username, email, password_hash, role, created_at",
) )
.bind(*user.id.as_uuid()) .bind(*user.id.as_uuid())
.bind(&user.username) .bind(&user.username)
.bind(user.email.as_str()) .bind(user.email.as_str())
.bind(user.password_hash.as_str()) .bind(user.password_hash.as_str())
.bind(&user.role)
.bind(user.created_at) .bind(user.created_at)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -113,7 +110,94 @@ impl UserRepository for PostgresUserRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(())
}
async fn count(&self) -> Result<u64, DomainError> {
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
.fetch_one(&self.pool)
.await
.map_pg()?;
Ok(count as u64)
}
}
// --- PostgresRefreshTokenRepository ---
#[derive(sqlx::FromRow)]
struct RefreshTokenRow {
token_id: Uuid,
user_id: Uuid,
token_hash: String,
expires_at: DateTime<Utc>,
revoked: bool,
created_at: DateTime<Utc>,
}
impl From<RefreshTokenRow> for RefreshToken {
fn from(r: RefreshTokenRow) -> Self {
Self {
token_id: SystemId::from_uuid(r.token_id),
user_id: SystemId::from_uuid(r.user_id),
token_hash: r.token_hash,
expires_at: DateTimeStamp::from_datetime(r.expires_at),
revoked: r.revoked,
created_at: DateTimeStamp::from_datetime(r.created_at),
}
}
}
pg_repo!(PostgresRefreshTokenRepository);
#[async_trait]
impl RefreshTokenRepository for PostgresRefreshTokenRepository {
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO refresh_tokens (token_id, user_id, token_hash, expires_at, revoked, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (token_id) DO UPDATE SET revoked = EXCLUDED.revoked",
)
.bind(*token.token_id.as_uuid())
.bind(*token.user_id.as_uuid())
.bind(&token.token_hash)
.bind(*token.expires_at.as_datetime())
.bind(token.revoked)
.bind(*token.created_at.as_datetime())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
let row = sqlx::query_as::<_, RefreshTokenRow>(
"SELECT token_id, user_id, token_hash, expires_at, revoked, created_at
FROM refresh_tokens WHERE token_hash = $1",
)
.bind(token_hash)
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_tokens WHERE user_id = $1")
.bind(*user_id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(())
}
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
sqlx::query("DELETE FROM refresh_tokens WHERE token_id = $1")
.bind(*id.as_uuid())
.execute(&self.pool)
.await
.map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,6 +1,8 @@
pub mod db; pub mod db;
mod helpers;
pub mod catalog; pub mod catalog;
pub mod event_store;
pub mod identity; pub mod identity;
pub mod organization; pub mod organization;
pub mod processing; pub mod processing;
@@ -11,6 +13,7 @@ pub mod storage;
pub use db::{PgPool, connect, run_migrations}; pub use db::{PgPool, connect, run_migrations};
pub use catalog::*; pub use catalog::*;
pub use event_store::PostgresEventStore;
pub use identity::*; pub use identity::*;
pub use organization::*; pub use organization::*;
pub use processing::*; pub use processing::*;

View File

@@ -1,4 +1,4 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
@@ -60,15 +60,9 @@ fn album_from_row(r: AlbumRow, entries: Vec<AlbumEntry>) -> Album {
} }
} }
pub struct PostgresAlbumRepository { pg_repo!(PostgresAlbumRepository);
pool: PgPool,
}
impl PostgresAlbumRepository { impl PostgresAlbumRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
async fn load_entries(&self, album_id: &Uuid) -> Result<Vec<AlbumEntry>, DomainError> { async fn load_entries(&self, album_id: &Uuid) -> Result<Vec<AlbumEntry>, DomainError> {
let rows = sqlx::query_as::<_, AlbumEntryRow>( let rows = sqlx::query_as::<_, AlbumEntryRow>(
"SELECT album_id, asset_id, sort_order, added_at, added_by_user_id "SELECT album_id, asset_id, sort_order, added_at, added_by_user_id
@@ -78,7 +72,7 @@ impl PostgresAlbumRepository {
.bind(album_id) .bind(album_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -95,7 +89,7 @@ impl AlbumRepository for PostgresAlbumRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
let Some(r) = row else { let Some(r) = row else {
return Ok(None); return Ok(None);
@@ -114,7 +108,7 @@ impl AlbumRepository for PostgresAlbumRepository {
.bind(*creator_id.as_uuid()) .bind(*creator_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
let mut albums = Vec::with_capacity(rows.len()); let mut albums = Vec::with_capacity(rows.len());
for r in rows { for r in rows {
@@ -146,14 +140,14 @@ impl AlbumRepository for PostgresAlbumRepository {
.bind(album.created_at.as_datetime()) .bind(album.created_at.as_datetime())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
// Sync entries: delete all then re-insert // Sync entries: delete all then re-insert
sqlx::query("DELETE FROM album_entries WHERE album_id = $1") sqlx::query("DELETE FROM album_entries WHERE album_id = $1")
.bind(*album.album_id.as_uuid()) .bind(*album.album_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
for entry in &album.entries { for entry in &album.entries {
sqlx::query( sqlx::query(
@@ -167,7 +161,7 @@ impl AlbumRepository for PostgresAlbumRepository {
.bind(*entry.added_by_user_id.as_uuid()) .bind(*entry.added_by_user_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
} }
Ok(()) Ok(())
@@ -179,7 +173,7 @@ impl AlbumRepository for PostgresAlbumRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -240,15 +234,7 @@ impl From<AssetTagRow> for AssetTag {
} }
} }
pub struct PostgresTagRepository { pg_repo!(PostgresTagRepository);
pool: PgPool,
}
impl PostgresTagRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl TagRepository for PostgresTagRepository { impl TagRepository for PostgresTagRepository {
@@ -259,7 +245,7 @@ impl TagRepository for PostgresTagRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -271,7 +257,7 @@ impl TagRepository for PostgresTagRepository {
.bind(name) .bind(name)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -288,7 +274,7 @@ impl TagRepository for PostgresTagRepository {
.bind(*asset_id.as_uuid()) .bind(*asset_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
let at_rows = sqlx::query_as::<_, AssetTagRow>( let at_rows = sqlx::query_as::<_, AssetTagRow>(
"SELECT asset_id, tag_id, tagged_by_user_id, confidence "SELECT asset_id, tag_id, tagged_by_user_id, confidence
@@ -297,7 +283,7 @@ impl TagRepository for PostgresTagRepository {
.bind(*asset_id.as_uuid()) .bind(*asset_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
let tags: Vec<Tag> = rows.into_iter().map(Into::into).collect(); let tags: Vec<Tag> = rows.into_iter().map(Into::into).collect();
let asset_tags: Vec<AssetTag> = at_rows.into_iter().map(Into::into).collect(); let asset_tags: Vec<AssetTag> = at_rows.into_iter().map(Into::into).collect();
@@ -316,7 +302,7 @@ impl TagRepository for PostgresTagRepository {
.bind(tag_source_to_str(&tag.tag_source)) .bind(tag_source_to_str(&tag.tag_source))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -334,7 +320,7 @@ impl TagRepository for PostgresTagRepository {
.bind(asset_tag.confidence) .bind(asset_tag.confidence)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -348,7 +334,7 @@ impl TagRepository for PostgresTagRepository {
.bind(*tag_id.as_uuid()) .bind(*tag_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
@@ -119,15 +119,7 @@ impl From<JobRow> for Job {
} }
} }
pub struct PostgresJobRepository { pg_repo!(PostgresJobRepository);
pool: PgPool,
}
impl PostgresJobRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl JobRepository for PostgresJobRepository { impl JobRepository for PostgresJobRepository {
@@ -141,7 +133,7 @@ impl JobRepository for PostgresJobRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -153,15 +145,90 @@ impl JobRepository for PostgresJobRepository {
started_at, completed_at, error_message started_at, completed_at, error_message
FROM jobs WHERE status = 'queued' FROM jobs WHERE status = 'queued'
ORDER BY priority DESC, created_at ASC ORDER BY priority DESC, created_at ASC
LIMIT 1", LIMIT 1
FOR UPDATE SKIP LOCKED",
) )
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
async fn claim_next(&self) -> Result<Option<Job>, DomainError> {
let row = sqlx::query_as::<_, JobRow>(
"UPDATE jobs SET status = 'processing', started_at = NOW()
WHERE job_id = (
SELECT job_id FROM jobs
WHERE status = 'queued'
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING job_id, job_type, target_asset_id, batch_id, status, priority,
payload, result_data, retry_count, max_retries, created_at,
started_at, completed_at, error_message",
)
.fetch_optional(&self.pool)
.await
.map_pg()?;
Ok(row.map(Into::into))
}
async fn find_all(
&self,
status: Option<&str>,
limit: u32,
offset: u32,
) -> Result<Vec<Job>, DomainError> {
let rows = match status {
Some(s) => sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
payload, result_data, retry_count, max_retries, created_at,
started_at, completed_at, error_message
FROM jobs WHERE status = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3",
)
.bind(s)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?,
None => sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
payload, result_data, retry_count, max_retries, created_at,
started_at, completed_at, error_message
FROM jobs
ORDER BY created_at DESC
LIMIT $1 OFFSET $2",
)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_pg()?,
};
Ok(rows.into_iter().map(Into::into).collect())
}
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError> {
let count: (i64,) = match status {
Some(s) => sqlx::query_as("SELECT COUNT(*) FROM jobs WHERE status = $1")
.bind(s)
.fetch_one(&self.pool)
.await
.map_pg()?,
None => sqlx::query_as("SELECT COUNT(*) FROM jobs")
.fetch_one(&self.pool)
.await
.map_pg()?,
};
Ok(count.0 as u64)
}
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> { async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> {
let rows = sqlx::query_as::<_, JobRow>( let rows = sqlx::query_as::<_, JobRow>(
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority, "SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
@@ -173,7 +240,7 @@ impl JobRepository for PostgresJobRepository {
.bind(*batch_id.as_uuid()) .bind(*batch_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -210,7 +277,7 @@ impl JobRepository for PostgresJobRepository {
.bind(&job.error_message) .bind(&job.error_message)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -261,15 +328,7 @@ impl From<BatchRow> for JobBatch {
} }
} }
pub struct PostgresJobBatchRepository { pg_repo!(PostgresJobBatchRepository);
pool: PgPool,
}
impl PostgresJobBatchRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl JobBatchRepository for PostgresJobBatchRepository { impl JobBatchRepository for PostgresJobBatchRepository {
@@ -281,7 +340,7 @@ impl JobBatchRepository for PostgresJobBatchRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -304,7 +363,7 @@ impl JobBatchRepository for PostgresJobBatchRepository {
.bind(batch_status_to_str(&batch.status)) .bind(batch_status_to_str(&batch.status))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -351,15 +410,7 @@ impl From<PluginRow> for Plugin {
} }
} }
pub struct PostgresPluginRepository { pg_repo!(PostgresPluginRepository);
pool: PgPool,
}
impl PostgresPluginRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl PluginRepository for PostgresPluginRepository { impl PluginRepository for PostgresPluginRepository {
@@ -371,11 +422,22 @@ impl PluginRepository for PostgresPluginRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError> {
let rows = sqlx::query_as::<_, PluginRow>(
"SELECT plugin_id, name, plugin_type, is_enabled, configuration FROM plugins",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> { async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
let rows = sqlx::query_as::<_, PluginRow>( let rows = sqlx::query_as::<_, PluginRow>(
"SELECT plugin_id, name, plugin_type, is_enabled, configuration "SELECT plugin_id, name, plugin_type, is_enabled, configuration
@@ -383,7 +445,7 @@ impl PluginRepository for PostgresPluginRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -405,7 +467,7 @@ impl PluginRepository for PostgresPluginRepository {
.bind(structured_to_json(&plugin.configuration)) .bind(structured_to_json(&plugin.configuration))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -475,15 +537,7 @@ impl From<PipelineRow> for ProcessingPipeline {
} }
} }
pub struct PostgresPipelineRepository { pg_repo!(PostgresPipelineRepository);
pool: PgPool,
}
impl PostgresPipelineRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl PipelineRepository for PostgresPipelineRepository { impl PipelineRepository for PostgresPipelineRepository {
@@ -495,11 +549,22 @@ impl PipelineRepository for PostgresPipelineRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
let rows = sqlx::query_as::<_, PipelineRow>(
"SELECT pipeline_id, trigger_event, steps FROM processing_pipelines",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> { async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
let rows = sqlx::query_as::<_, PipelineRow>( let rows = sqlx::query_as::<_, PipelineRow>(
"SELECT pipeline_id, trigger_event, steps "SELECT pipeline_id, trigger_event, steps
@@ -508,7 +573,7 @@ impl PipelineRepository for PostgresPipelineRepository {
.bind(event) .bind(event)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -526,7 +591,7 @@ impl PipelineRepository for PostgresPipelineRepository {
.bind(steps_to_json(&pipeline.steps)) .bind(steps_to_json(&pipeline.steps))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
@@ -207,15 +207,7 @@ impl TryFrom<InviteCodeRow> for InviteCode {
} }
} }
pub struct PostgresShareRepository { pg_repo!(PostgresShareRepository);
pool: PgPool,
}
impl PostgresShareRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl ShareRepository for PostgresShareRepository { impl ShareRepository for PostgresShareRepository {
@@ -238,7 +230,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(scope.created_at.as_datetime()) .bind(scope.created_at.as_datetime())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -250,7 +242,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -266,7 +258,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*resource_id.as_uuid()) .bind(*resource_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -276,7 +268,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -296,7 +288,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*target.role_id.as_uuid()) .bind(*target.role_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -311,7 +303,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*scope_id.as_uuid()) .bind(*scope_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -327,7 +319,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*user_id.as_uuid()) .bind(*user_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -355,7 +347,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(link.use_count as i32) .bind(link.use_count as i32)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -367,7 +359,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(token) .bind(token)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -392,7 +384,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*invite.assigned_role_id.as_uuid()) .bind(*invite.assigned_role_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -404,7 +396,7 @@ impl ShareRepository for PostgresShareRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -433,15 +425,7 @@ impl From<VisibilityFilterRow> for VisibilityFilter {
} }
} }
pub struct PostgresVisibilityFilterRepository { pg_repo!(PostgresVisibilityFilterRepository);
pool: PgPool,
}
impl PostgresVisibilityFilterRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl VisibilityFilterRepository for PostgresVisibilityFilterRepository { impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
@@ -458,7 +442,7 @@ impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
.bind(*role_id.as_uuid()) .bind(*role_id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -476,7 +460,7 @@ impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
.bind(&filter.hidden_fields) .bind(&filter.hidden_fields)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -485,7 +469,7 @@ impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,4 +1,4 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
@@ -55,15 +55,7 @@ impl TryFrom<SidecarRow> for SidecarRecord {
} }
} }
pub struct PostgresSidecarRepository { pg_repo!(PostgresSidecarRepository);
pool: PgPool,
}
impl PostgresSidecarRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl SidecarRepository for PostgresSidecarRepository { impl SidecarRepository for PostgresSidecarRepository {
@@ -79,7 +71,7 @@ impl SidecarRepository for PostgresSidecarRepository {
.bind(*asset_id.as_uuid()) .bind(*asset_id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -93,7 +85,7 @@ impl SidecarRepository for PostgresSidecarRepository {
.bind(sync_status_to_str(&status)) .bind(sync_status_to_str(&status))
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -118,7 +110,7 @@ impl SidecarRepository for PostgresSidecarRepository {
.bind(&record.error_message) .bind(&record.error_message)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -127,7 +119,7 @@ impl SidecarRepository for PostgresSidecarRepository {
.bind(*asset_id.as_uuid()) .bind(*asset_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }

View File

@@ -1,15 +1,15 @@
use crate::db::PgPool; use crate::helpers::{MapDomainError, pg_repo};
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use domain::{ use domain::{
entities::{ entities::{
IngestSession, IngestStatus, LibraryPath, OwnershipPolicy, QuotaDefinition, QuotaRule, Asset, IngestSession, IngestStatus, LibraryPath, OwnershipPolicy, QuotaDefinition,
StorageVolume, TimePeriod, UsageLedgerEntry, UsageType, QuotaRule, StorageVolume, TimePeriod, UsageLedgerEntry, UsageType,
}, },
errors::DomainError, errors::DomainError,
ports::{ ports::{
IngestSessionRepository, LibraryPathRepository, QuotaRepository, StorageVolumeRepository, IngestSessionRepository, IngestTransaction, LibraryPathRepository, QuotaRepository,
UsageLedgerRepository, StorageVolumeRepository, UsageLedgerRepository,
}, },
value_objects::{Checksum, DateTimeStamp, SystemId}, value_objects::{Checksum, DateTimeStamp, SystemId},
}; };
@@ -40,15 +40,7 @@ impl From<StorageVolumeRow> for StorageVolume {
} }
} }
pub struct PostgresStorageVolumeRepository { pg_repo!(PostgresStorageVolumeRepository);
pool: PgPool,
}
impl PostgresStorageVolumeRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl StorageVolumeRepository for PostgresStorageVolumeRepository { impl StorageVolumeRepository for PostgresStorageVolumeRepository {
@@ -60,7 +52,7 @@ impl StorageVolumeRepository for PostgresStorageVolumeRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
@@ -72,7 +64,7 @@ impl StorageVolumeRepository for PostgresStorageVolumeRepository {
) )
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -94,7 +86,7 @@ impl StorageVolumeRepository for PostgresStorageVolumeRepository {
.bind(volume.available_bytes as i64) .bind(volume.available_bytes as i64)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -103,7 +95,7 @@ impl StorageVolumeRepository for PostgresStorageVolumeRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -151,15 +143,7 @@ impl From<LibraryPathRow> for LibraryPath {
} }
} }
pub struct PostgresLibraryPathRepository { pg_repo!(PostgresLibraryPathRepository);
pool: PgPool,
}
impl PostgresLibraryPathRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl LibraryPathRepository for PostgresLibraryPathRepository { impl LibraryPathRepository for PostgresLibraryPathRepository {
@@ -171,11 +155,23 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.map(Into::into)) Ok(row.map(Into::into))
} }
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError> {
let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
FROM library_paths",
)
.fetch_all(&self.pool)
.await
.map_pg()?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> { async fn find_by_volume(&self, volume_id: &SystemId) -> Result<Vec<LibraryPath>, DomainError> {
let rows = sqlx::query_as::<_, LibraryPathRow>( let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id "SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
@@ -184,7 +180,7 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
.bind(*volume_id.as_uuid()) .bind(*volume_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -196,12 +192,12 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
let rows = sqlx::query_as::<_, LibraryPathRow>( let rows = sqlx::query_as::<_, LibraryPathRow>(
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id "SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
FROM library_paths FROM library_paths
WHERE is_ingest_destination = true AND designated_owner_id = $1", WHERE is_ingest_destination = true AND (designated_owner_id = $1 OR designated_owner_id IS NULL)",
) )
.bind(*owner_id.as_uuid()) .bind(*owner_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(rows.into_iter().map(Into::into).collect()) Ok(rows.into_iter().map(Into::into).collect())
} }
@@ -225,7 +221,7 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
.bind(path.designated_owner_id.as_ref().map(|id| *id.as_uuid())) .bind(path.designated_owner_id.as_ref().map(|id| *id.as_uuid()))
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -234,7 +230,7 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -294,15 +290,7 @@ impl TryFrom<IngestSessionRow> for IngestSession {
} }
} }
pub struct PostgresIngestSessionRepository { pg_repo!(PostgresIngestSessionRepository);
pool: PgPool,
}
impl PostgresIngestSessionRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl IngestSessionRepository for PostgresIngestSessionRepository { impl IngestSessionRepository for PostgresIngestSessionRepository {
@@ -315,7 +303,7 @@ impl IngestSessionRepository for PostgresIngestSessionRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
row.map(TryInto::try_into).transpose() row.map(TryInto::try_into).transpose()
} }
@@ -329,7 +317,7 @@ impl IngestSessionRepository for PostgresIngestSessionRepository {
.bind(*user_id.as_uuid()) .bind(*user_id.as_uuid())
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
rows.into_iter().map(TryInto::try_into).collect() rows.into_iter().map(TryInto::try_into).collect()
} }
@@ -354,7 +342,7 @@ impl IngestSessionRepository for PostgresIngestSessionRepository {
.bind(session.error_message.as_deref()) .bind(session.error_message.as_deref())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
@@ -457,15 +445,7 @@ impl From<UsageLedgerRow> for UsageLedgerEntry {
} }
} }
pub struct PostgresQuotaRepository { pg_repo!(PostgresQuotaRepository);
pool: PgPool,
}
impl PostgresQuotaRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl QuotaRepository for PostgresQuotaRepository { impl QuotaRepository for PostgresQuotaRepository {
@@ -479,7 +459,7 @@ impl QuotaRepository for PostgresQuotaRepository {
.bind(*owner_id.as_uuid()) .bind(*owner_id.as_uuid())
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
let Some(def) = def_row else { let Some(def) = def_row else {
return Ok(None); return Ok(None);
@@ -492,7 +472,7 @@ impl QuotaRepository for PostgresQuotaRepository {
.bind(def.quota_id) .bind(def.quota_id)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(Some(QuotaDefinition { Ok(Some(QuotaDefinition {
quota_id: SystemId::from_uuid(def.quota_id), quota_id: SystemId::from_uuid(def.quota_id),
@@ -515,14 +495,14 @@ impl QuotaRepository for PostgresQuotaRepository {
.bind(quota.is_enforced) .bind(quota.is_enforced)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
// Delete old rules then re-insert // Delete old rules then re-insert
sqlx::query("DELETE FROM quota_rules WHERE quota_id = $1") sqlx::query("DELETE FROM quota_rules WHERE quota_id = $1")
.bind(*quota.quota_id.as_uuid()) .bind(*quota.quota_id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
for rule in &quota.rules { for rule in &quota.rules {
sqlx::query( sqlx::query(
@@ -537,7 +517,7 @@ impl QuotaRepository for PostgresQuotaRepository {
.bind(rule.is_unlimited) .bind(rule.is_unlimited)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
} }
Ok(()) Ok(())
@@ -549,20 +529,12 @@ impl QuotaRepository for PostgresQuotaRepository {
.bind(*id.as_uuid()) .bind(*id.as_uuid())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
} }
pub struct PostgresUsageLedgerRepository { pg_repo!(PostgresUsageLedgerRepository);
pool: PgPool,
}
impl PostgresUsageLedgerRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait] #[async_trait]
impl UsageLedgerRepository for PostgresUsageLedgerRepository { impl UsageLedgerRepository for PostgresUsageLedgerRepository {
@@ -579,7 +551,7 @@ impl UsageLedgerRepository for PostgresUsageLedgerRepository {
.bind(&entry.context) .bind(&entry.context)
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(()) Ok(())
} }
@@ -600,8 +572,56 @@ impl UsageLedgerRepository for PostgresUsageLedgerRepository {
.bind(since_dt) .bind(since_dt)
.fetch_one(&self.pool) .fetch_one(&self.pool)
.await .await
.map_err(|e| DomainError::Internal(e.to_string()))?; .map_pg()?;
Ok(row.total as u64) Ok(row.total as u64)
} }
} }
// ──────────────────────────────────────────────
// IngestTransaction (composite port)
// ──────────────────────────────────────────────
pg_repo!(PostgresIngestTransaction);
#[async_trait]
impl IngestTransaction for PostgresIngestTransaction {
async fn save_asset(&self, asset: &Asset) -> Result<(), DomainError> {
use domain::ports::AssetRepository;
crate::PostgresAssetRepository::new(self.pool.clone())
.save(asset)
.await
}
async fn save_session(&self, session: &IngestSession) -> Result<(), DomainError> {
PostgresIngestSessionRepository::new(self.pool.clone())
.save(session)
.await
}
async fn find_quota(
&self,
owner_id: &SystemId,
) -> Result<Option<QuotaDefinition>, DomainError> {
PostgresQuotaRepository::new(self.pool.clone())
.find_by_owner(owner_id)
.await
}
async fn sum_usage(
&self,
user_id: &SystemId,
usage_type: UsageType,
since: Option<DateTimeStamp>,
) -> Result<u64, DomainError> {
PostgresUsageLedgerRepository::new(self.pool.clone())
.sum_usage(user_id, usage_type, since)
.await
}
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError> {
PostgresUsageLedgerRepository::new(self.pool.clone())
.record(entry)
.await
}
}

View File

@@ -0,0 +1,11 @@
[package]
name = "adapters-sidecar"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
xmp_toolkit = "1.11"
tracing = { workspace = true }

View File

@@ -0,0 +1,139 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::SidecarWriterPort,
value_objects::{MetadataValue, StructuredData},
};
use xmp_toolkit::{
XmpMeta, XmpValue,
xmp_ns::{DC, EXIF, XMP},
};
pub struct XmpSidecarWriter;
const EXIF_TAGS: &[&str] = &[
"DateTimeOriginal",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"FocalLength",
"Make",
"Model",
"LensModel",
"GPSLatitude",
"GPSLongitude",
"GPSAltitude",
"Orientation",
"ImageWidth",
"ImageHeight",
"Flash",
"MeteringMode",
"WhiteBalance",
"ExposureProgram",
"ExposureBiasValue",
"ModifyDate",
"Software",
];
#[async_trait]
impl SidecarWriterPort for XmpSidecarWriter {
fn format_name(&self) -> &str {
"xmp"
}
async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError> {
let mut xmp =
XmpMeta::new().map_err(|e| DomainError::Internal(format!("xmp init failed: {e}")))?;
register_namespaces()?;
for (key, value) in data.inner() {
let value_str = match value {
MetadataValue::String(s) => s.clone(),
MetadataValue::Integer(i) => i.to_string(),
MetadataValue::Float(f) => f.to_string(),
MetadataValue::Boolean(b) => b.to_string(),
MetadataValue::Null => continue,
};
let ns = if EXIF_TAGS.contains(&key.as_str()) || key.starts_with("track:") {
EXIF
} else if key == "title" || key == "description" || key == "subject" {
DC
} else {
XMP
};
set_prop(&mut xmp, ns, key, &value_str)?;
}
let xmp_str = xmp.to_string();
let path = path.to_string();
tokio::task::spawn_blocking(move || {
if let Some(parent) = std::path::Path::new(&path).parent() {
std::fs::create_dir_all(parent)
.map_err(|e| DomainError::Internal(format!("mkdir failed: {e}")))?;
}
std::fs::write(&path, xmp_str)
.map_err(|e| DomainError::Internal(format!("write failed: {e}")))
})
.await
.map_err(|e| DomainError::Internal(format!("spawn_blocking failed: {e}")))?
}
async fn read_sidecar(&self, path: &str) -> Result<StructuredData, DomainError> {
let path = path.to_string();
let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
DomainError::NotFound(format!("sidecar not found: {path}"))
} else {
DomainError::Internal(format!("read failed: {e}"))
}
})?;
let xmp: XmpMeta = content
.parse()
.map_err(|e| DomainError::Internal(format!("xmp parse failed: {e}")))?;
let mut data = StructuredData::new();
for ns in [DC, EXIF, XMP] {
let iter = xmp.iter(xmp_toolkit::IterOptions::default().schema_ns(ns));
for prop in iter {
if prop.name.is_empty() || prop.value.value.is_empty() {
continue;
}
let key = prop
.name
.split(':')
.next_back()
.unwrap_or(&prop.name)
.to_string();
if !key.is_empty() {
data.insert(key, MetadataValue::String(prop.value.value));
}
}
}
Ok(data)
}
}
fn register_namespaces() -> Result<(), DomainError> {
XmpMeta::register_namespace(DC, "dc")
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
XmpMeta::register_namespace(EXIF, "exif")
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
XmpMeta::register_namespace(XMP, "xmp")
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
Ok(())
}
fn set_prop(xmp: &mut XmpMeta, ns: &str, key: &str, value: &str) -> Result<(), DomainError> {
xmp.set_property(ns, key, &XmpValue::from(value))
.map_err(|e| DomainError::Internal(format!("set {key} failed: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,45 @@
use crate::XmpSidecarWriter;
use domain::{
ports::SidecarWriterPort,
value_objects::{MetadataValue, StructuredData},
};
fn sample_metadata() -> StructuredData {
let mut data = StructuredData::new();
data.insert("Make", MetadataValue::String("Canon".into()));
data.insert("Model", MetadataValue::String("EOS R5".into()));
data.insert(
"DateTimeOriginal",
MetadataValue::String("2024:06:15 14:30:00".into()),
);
data.insert("ISOSpeedRatings", MetadataValue::Integer(800));
data
}
#[tokio::test]
async fn write_and_read_roundtrip() {
let writer = XmpSidecarWriter;
let data = sample_metadata();
let path = "/tmp/k-photos-test-sidecar-roundtrip.xmp";
writer.write_sidecar(&data, path).await.unwrap();
let read_back = writer.read_sidecar(path).await.unwrap();
assert_eq!(read_back.get_string("Make"), Some("Canon"));
assert_eq!(read_back.get_string("Model"), Some("EOS R5"));
tokio::fs::remove_file(path).await.ok();
}
#[tokio::test]
async fn read_missing_returns_not_found() {
let writer = XmpSidecarWriter;
let result = writer.read_sidecar("/tmp/nonexistent-xmp-file.xmp").await;
assert!(result.is_err());
}
#[tokio::test]
async fn format_name_is_xmp() {
let writer = XmpSidecarWriter;
assert_eq!(writer.format_name(), "xmp");
}

View File

@@ -16,6 +16,7 @@ tracing = { workspace = true }
bytes = { workspace = true } bytes = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
tokio = { workspace = true, features = ["fs"] } tokio = { workspace = true, features = ["fs"] }
tokio-util = { workspace = true }
object_store = { version = "0.11" } object_store = { version = "0.11" }
[dev-dependencies] [dev-dependencies]

View File

@@ -146,199 +146,3 @@ impl StorageReader for ObjectStorageAdapter {
Ok(result) Ok(result)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use domain::ports::{StorageReader, StorageWriter};
use futures::stream;
use object_store::memory::InMemory;
fn make_adapter() -> ObjectStorageAdapter {
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
}
fn one_shot(data: &'static [u8]) -> DataStream {
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
}
#[tokio::test]
async fn put_get_roundtrip() {
let a = make_adapter();
a.put("hello.txt", one_shot(b"world")).await.unwrap();
let mut s = a.get("hello.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"world");
}
#[tokio::test]
async fn get_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(
a.get("nope.txt").await,
Err(DomainError::NotFound(_))
));
}
#[tokio::test]
async fn delete_is_idempotent() {
let a = make_adapter();
a.delete("nope.txt").await.unwrap();
}
#[tokio::test]
async fn delete_removes_key() {
let a = make_adapter();
a.put("file.txt", one_shot(b"data")).await.unwrap();
a.delete("file.txt").await.unwrap();
assert!(matches!(
a.get("file.txt").await,
Err(DomainError::NotFound(_))
));
}
#[tokio::test]
async fn list_returns_keys_under_prefix() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
let keys = a.list(Some("docs")).await.unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
}
#[tokio::test]
async fn list_none_returns_all() {
let a = make_adapter();
a.put("a.txt", one_shot(b"1")).await.unwrap();
a.put("b.txt", one_shot(b"2")).await.unwrap();
let keys = a.list(None).await.unwrap();
assert_eq!(keys.len(), 2);
}
#[tokio::test]
async fn rejects_empty_key() {
let a = make_adapter();
assert!(matches!(
a.put("", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
assert!(matches!(
a.delete("").await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_absolute_key() {
let a = make_adapter();
assert!(matches!(
a.put("/etc/passwd", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_path_traversal() {
let a = make_adapter();
assert!(matches!(
a.get("../escape").await,
Err(DomainError::Validation(_))
));
assert!(matches!(
a.get("a/../../../etc").await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_dot_segment() {
let a = make_adapter();
assert!(matches!(
a.put("./file.txt", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_invalid_list_prefix() {
let a = make_adapter();
assert!(matches!(
a.list(Some("")).await,
Err(DomainError::Validation(_))
));
assert!(matches!(
a.list(Some("../escape")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn put_overwrites_existing() {
let a = make_adapter();
a.put("file.txt", one_shot(b"version1")).await.unwrap();
a.put("file.txt", one_shot(b"version2")).await.unwrap();
let mut s = a.get("file.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"version2");
}
#[tokio::test]
async fn list_returns_exact_key_paths() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
let mut keys = a.list(Some("docs")).await.unwrap();
keys.sort();
assert_eq!(keys, vec!["docs/readme.txt"]);
}
#[tokio::test]
async fn put_bytes_get_bytes_roundtrip() {
let a = make_adapter();
a.put_bytes("data.bin", Bytes::from("hello bytes"))
.await
.unwrap();
let got = a.get_bytes("data.bin").await.unwrap();
assert_eq!(got.as_ref(), b"hello bytes");
}
#[tokio::test]
async fn get_bytes_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(
a.get_bytes("nope.bin").await,
Err(DomainError::NotFound(_))
));
}
#[test]
fn new_rejects_traversal_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_rejects_absolute_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_accepts_empty_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
}
#[test]
fn new_accepts_valid_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
}
}

View File

@@ -1,7 +1,9 @@
pub mod adapter; pub mod adapter;
pub mod config; pub mod config;
pub mod local_file_storage; pub mod local_file_storage;
pub mod volume_resolver;
pub use adapter::ObjectStorageAdapter; pub use adapter::ObjectStorageAdapter;
pub use config::{StorageConfig, build_store}; pub use config::{StorageConfig, build_store};
pub use local_file_storage::LocalFileStorage; pub use local_file_storage::LocalFileStorage;
pub use volume_resolver::LocalVolumeFileResolver;

View File

@@ -1,8 +1,10 @@
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::ports::{FileEntry, FileStoragePort}; use domain::ports::{DataStream, FileEntry, FileStoragePort};
use futures::StreamExt;
use std::path::PathBuf; use std::path::PathBuf;
use tokio_util::io::ReaderStream;
pub struct LocalFileStorage { pub struct LocalFileStorage {
base_path: PathBuf, base_path: PathBuf,
@@ -51,6 +53,25 @@ impl FileStoragePort for LocalFileStorage {
Ok(Bytes::from(data)) Ok(Bytes::from(data))
} }
async fn open_file(&self, path: &str) -> Result<(DataStream, u64), DomainError> {
let full = self.resolve(path)?;
let meta = tokio::fs::metadata(&full)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
_ => DomainError::Internal(format!("Failed to stat file: {e}")),
})?;
let file = tokio::fs::File::open(&full)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => DomainError::NotFound(path.to_string()),
_ => DomainError::Internal(format!("Failed to open file: {e}")),
})?;
let stream = ReaderStream::new(file)
.map(|r| r.map_err(|e| DomainError::Internal(format!("Read error: {e}"))));
Ok((Box::pin(stream), meta.len()))
}
async fn delete_file(&self, path: &str) -> Result<(), DomainError> { async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
let full = self.resolve(path)?; let full = self.resolve(path)?;
match tokio::fs::remove_file(&full).await { match tokio::fs::remove_file(&full).await {

View File

@@ -0,0 +1,91 @@
use async_trait::async_trait;
use bytes::Bytes;
use domain::{
errors::DomainError,
ports::{DataStream, StorageVolumeRepository, VolumeFileResolver},
value_objects::SystemId,
};
use futures::StreamExt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::io::ReaderStream;
pub struct LocalVolumeFileResolver {
volume_repo: Arc<dyn StorageVolumeRepository>,
}
impl LocalVolumeFileResolver {
pub fn new(volume_repo: Arc<dyn StorageVolumeRepository>) -> Self {
Self { volume_repo }
}
async fn resolve_path(
&self,
volume_id: &SystemId,
relative_path: &str,
) -> Result<PathBuf, DomainError> {
let volume = self
.volume_repo
.find_by_id(volume_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Volume {} not found", volume_id)))?;
let base = volume
.uri_prefix
.strip_prefix("file://")
.unwrap_or(&volume.uri_prefix);
let full = if relative_path.is_empty() {
PathBuf::from(base)
} else {
PathBuf::from(base).join(relative_path)
};
Ok(full)
}
}
#[async_trait]
impl VolumeFileResolver for LocalVolumeFileResolver {
async fn open_by_volume(
&self,
volume_id: &SystemId,
relative_path: &str,
) -> Result<(DataStream, u64), DomainError> {
let full = self.resolve_path(volume_id, relative_path).await?;
let meta = tokio::fs::metadata(&full)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
DomainError::NotFound(full.display().to_string())
}
_ => DomainError::Internal(format!("Failed to stat file: {e}")),
})?;
let file = tokio::fs::File::open(&full)
.await
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
DomainError::NotFound(full.display().to_string())
}
_ => DomainError::Internal(format!("Failed to open file: {e}")),
})?;
let stream = ReaderStream::new(file)
.map(|r| r.map_err(|e| DomainError::Internal(format!("Read error: {e}"))));
Ok((Box::pin(stream), meta.len()))
}
async fn read_by_volume(
&self,
volume_id: &SystemId,
relative_path: &str,
) -> Result<Bytes, DomainError> {
let full = self.resolve_path(volume_id, relative_path).await?;
let data = tokio::fs::read(&full).await.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
DomainError::NotFound(full.display().to_string())
}
_ => DomainError::Internal(format!("Failed to read file: {e}")),
})?;
Ok(Bytes::from(data))
}
}

View File

@@ -0,0 +1,197 @@
use std::sync::Arc;
use adapters_storage::ObjectStorageAdapter;
use bytes::Bytes;
use domain::errors::DomainError;
use domain::ports::{DataStream, StorageReader, StorageWriter};
use futures::stream;
use futures::stream::StreamExt;
use object_store::memory::InMemory;
fn make_adapter() -> ObjectStorageAdapter {
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
}
fn one_shot(data: &'static [u8]) -> DataStream {
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
}
#[tokio::test]
async fn put_get_roundtrip() {
let a = make_adapter();
a.put("hello.txt", one_shot(b"world")).await.unwrap();
let mut s = a.get("hello.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"world");
}
#[tokio::test]
async fn get_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(
a.get("nope.txt").await,
Err(DomainError::NotFound(_))
));
}
#[tokio::test]
async fn delete_is_idempotent() {
let a = make_adapter();
a.delete("nope.txt").await.unwrap();
}
#[tokio::test]
async fn delete_removes_key() {
let a = make_adapter();
a.put("file.txt", one_shot(b"data")).await.unwrap();
a.delete("file.txt").await.unwrap();
assert!(matches!(
a.get("file.txt").await,
Err(DomainError::NotFound(_))
));
}
#[tokio::test]
async fn list_returns_keys_under_prefix() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
let keys = a.list(Some("docs")).await.unwrap();
assert_eq!(keys.len(), 2);
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
}
#[tokio::test]
async fn list_none_returns_all() {
let a = make_adapter();
a.put("a.txt", one_shot(b"1")).await.unwrap();
a.put("b.txt", one_shot(b"2")).await.unwrap();
let keys = a.list(None).await.unwrap();
assert_eq!(keys.len(), 2);
}
#[tokio::test]
async fn rejects_empty_key() {
let a = make_adapter();
assert!(matches!(
a.put("", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
assert!(matches!(
a.delete("").await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_absolute_key() {
let a = make_adapter();
assert!(matches!(
a.put("/etc/passwd", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_path_traversal() {
let a = make_adapter();
assert!(matches!(
a.get("../escape").await,
Err(DomainError::Validation(_))
));
assert!(matches!(
a.get("a/../../../etc").await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_dot_segment() {
let a = make_adapter();
assert!(matches!(
a.put("./file.txt", one_shot(b"x")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn rejects_invalid_list_prefix() {
let a = make_adapter();
assert!(matches!(
a.list(Some("")).await,
Err(DomainError::Validation(_))
));
assert!(matches!(
a.list(Some("../escape")).await,
Err(DomainError::Validation(_))
));
}
#[tokio::test]
async fn put_overwrites_existing() {
let a = make_adapter();
a.put("file.txt", one_shot(b"version1")).await.unwrap();
a.put("file.txt", one_shot(b"version2")).await.unwrap();
let mut s = a.get("file.txt").await.unwrap();
let mut out = Vec::new();
while let Some(chunk) = s.next().await {
out.extend_from_slice(&chunk.unwrap());
}
assert_eq!(out, b"version2");
}
#[tokio::test]
async fn list_returns_exact_key_paths() {
let a = make_adapter();
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
let mut keys = a.list(Some("docs")).await.unwrap();
keys.sort();
assert_eq!(keys, vec!["docs/readme.txt"]);
}
#[tokio::test]
async fn put_bytes_get_bytes_roundtrip() {
let a = make_adapter();
a.put_bytes("data.bin", Bytes::from("hello bytes"))
.await
.unwrap();
let got = a.get_bytes("data.bin").await.unwrap();
assert_eq!(got.as_ref(), b"hello bytes");
}
#[tokio::test]
async fn get_bytes_missing_is_not_found() {
let a = make_adapter();
assert!(matches!(
a.get_bytes("nope.bin").await,
Err(DomainError::NotFound(_))
));
}
#[test]
fn new_rejects_traversal_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_rejects_absolute_prefix() {
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
assert!(matches!(result, Err(DomainError::Validation(_))));
}
#[test]
fn new_accepts_empty_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
}
#[test]
fn new_accepts_valid_prefix() {
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
}

View File

@@ -0,0 +1,9 @@
[package]
name = "adapters-thumbnail"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
bytes = { workspace = true }
image = "0.25"

View File

@@ -0,0 +1,57 @@
use bytes::Bytes;
use domain::{
errors::DomainError,
ports::{ThumbnailGeneratorPort, ThumbnailOutput},
};
use image::{DynamicImage, ImageFormat, load_from_memory};
use std::io::Cursor;
pub struct ImageThumbnailGenerator;
impl ThumbnailGeneratorPort for ImageThumbnailGenerator {
fn generate(
&self,
source: &Bytes,
width: u32,
height: u32,
format: &str,
) -> Result<ThumbnailOutput, DomainError> {
let img = load_from_memory(source)
.map_err(|e| DomainError::Internal(format!("failed to decode image: {e}")))?;
let thumb = img.thumbnail(width, height);
let (img_format, mime) = parse_format(format)?;
let encoded = encode(&thumb, img_format)?;
let actual_width = thumb.width();
let actual_height = thumb.height();
Ok(ThumbnailOutput {
bytes: Bytes::from(encoded),
width: actual_width,
height: actual_height,
mime_type: mime.to_string(),
})
}
}
fn parse_format(s: &str) -> Result<(ImageFormat, &'static str), DomainError> {
match s {
"jpeg" | "jpg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
"webp" => Ok((ImageFormat::WebP, "image/webp")),
"png" => Ok((ImageFormat::Png, "image/png")),
other => Err(DomainError::Validation(format!(
"unsupported thumbnail format: {other}"
))),
}
}
fn encode(img: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, DomainError> {
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, format)
.map_err(|e| DomainError::Internal(format!("failed to encode thumbnail: {e}")))?;
Ok(buf.into_inner())
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,66 @@
use crate::ImageThumbnailGenerator;
use bytes::Bytes;
use domain::ports::ThumbnailGeneratorPort as _;
fn make_test_png() -> Bytes {
use image::{ImageFormat, RgbImage};
use std::io::Cursor;
let img = RgbImage::new(100, 200);
let mut buf = Cursor::new(Vec::new());
img.write_to(&mut buf, ImageFormat::Png).unwrap();
Bytes::from(buf.into_inner())
}
#[test]
fn generates_jpeg_thumbnail() {
let generator = ImageThumbnailGenerator;
let source = make_test_png();
let out = generator.generate(&source, 50, 50, "jpeg").unwrap();
assert!(out.width <= 50);
assert!(out.height <= 50);
assert_eq!(out.mime_type, "image/jpeg");
assert!(!out.bytes.is_empty());
}
#[test]
fn generates_webp_thumbnail() {
let generator = ImageThumbnailGenerator;
let source = make_test_png();
let out = generator.generate(&source, 30, 30, "webp").unwrap();
assert!(out.width <= 30);
assert!(out.height <= 30);
assert_eq!(out.mime_type, "image/webp");
}
#[test]
fn preserves_aspect_ratio() {
let generator = ImageThumbnailGenerator;
let source = make_test_png(); // 100x200
let out = generator.generate(&source, 50, 50, "png").unwrap();
// 100x200 → fits in 50x50 → 25x50
assert_eq!(out.width, 25);
assert_eq!(out.height, 50);
}
#[test]
fn rejects_unsupported_format() {
let generator = ImageThumbnailGenerator;
let source = make_test_png();
let result = generator.generate(&source, 50, 50, "bmp");
assert!(result.is_err());
}
#[test]
fn rejects_garbage_input() {
let generator = ImageThumbnailGenerator;
let result = generator.generate(&Bytes::from_static(b"not an image"), 50, 50, "jpeg");
assert!(result.is_err());
}

View File

@@ -11,6 +11,11 @@ pub struct LoginRequest {
pub password: String, pub password: String,
} }
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct RefreshTokenRequest {
pub refresh_token: String,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateAlbumRequest { pub struct CreateAlbumRequest {
pub title: String, pub title: String,
@@ -86,6 +91,56 @@ pub struct RegisterAssetRequest {
pub file_size: u64, pub file_size: u64,
} }
// --- Bulk ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct BulkDeleteRequest {
pub asset_ids: Vec<uuid::Uuid>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct BulkTagRequest {
pub asset_ids: Vec<uuid::Uuid>,
pub tag_name: String,
}
// --- Album update ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct UpdateAlbumRequest {
pub title: Option<String>,
pub description: Option<String>,
}
// --- Stack reorder ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ReorderStackRequest {
pub member_order: Vec<uuid::Uuid>,
}
// --- Stacks ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct CreateStackRequest {
pub stack_type: String,
pub primary_asset_id: uuid::Uuid,
pub members: Vec<StackMemberRequest>,
}
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct StackMemberRequest {
pub asset_id: uuid::Uuid,
pub role: String,
}
// --- Duplicates ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
pub struct ResolveDuplicateRequest {
pub keep_asset_id: uuid::Uuid,
}
// --- Sidecar --- // --- Sidecar ---
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] #[derive(Debug, serde::Deserialize, utoipa::ToSchema)]

View File

@@ -6,12 +6,14 @@ pub struct UserResponse {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub email: String, pub email: String,
pub role: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct AuthResponse { pub struct AuthResponse {
pub token: String, pub token: String,
pub refresh_token: String,
pub user: UserResponse, pub user: UserResponse,
} }
@@ -21,6 +23,7 @@ impl UserResponse {
id: *user.id.as_uuid(), id: *user.id.as_uuid(),
username: user.username.clone(), username: user.username.clone(),
email: user.email.to_string(), email: user.email.to_string(),
role: user.role.clone(),
created_at: user.created_at, created_at: user.created_at,
} }
} }
@@ -33,6 +36,7 @@ pub struct AlbumResponse {
pub description: String, pub description: String,
pub creator_id: Uuid, pub creator_id: Uuid,
pub asset_count: usize, pub asset_count: usize,
pub asset_ids: Vec<Uuid>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
@@ -44,6 +48,7 @@ impl AlbumResponse {
description: album.description.clone(), description: album.description.clone(),
creator_id: *album.creator_user_id.as_uuid(), creator_id: *album.creator_user_id.as_uuid(),
asset_count: album.asset_count(), asset_count: album.asset_count(),
asset_ids: album.entries.iter().map(|e| *e.asset_id.as_uuid()).collect(),
created_at: *album.created_at.as_datetime(), created_at: *album.created_at.as_datetime(),
} }
} }
@@ -83,10 +88,21 @@ impl AssetResponse {
} }
} }
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct DateSummaryResponse {
pub dates: Vec<DateCountEntry>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct DateCountEntry {
pub date: String,
pub count: u64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct TimelineResponse { pub struct TimelineResponse {
pub assets: Vec<AssetResponse>, pub assets: Vec<AssetResponse>,
pub total: usize, pub total: u64,
} }
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
@@ -267,6 +283,78 @@ pub struct SidecarImportResponse {
pub status: String, pub status: String,
} }
// --- Stacks ---
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct StackResponse {
pub stack_id: Uuid,
pub stack_type: String,
pub primary_asset_id: Uuid,
pub owner_user_id: Uuid,
pub members: Vec<StackMemberResponse>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct StackMemberResponse {
pub asset_id: Uuid,
pub role: String,
pub sort_order: u32,
}
impl StackResponse {
pub fn from_domain(stack: &domain::entities::AssetStack) -> Self {
Self {
stack_id: *stack.stack_id.as_uuid(),
stack_type: format!("{:?}", stack.stack_type),
primary_asset_id: *stack.primary_asset_id.as_uuid(),
owner_user_id: *stack.owner_user_id.as_uuid(),
members: stack
.members
.iter()
.map(|m| StackMemberResponse {
asset_id: *m.asset_id.as_uuid(),
role: format!("{:?}", m.role),
sort_order: m.sort_order,
})
.collect(),
}
}
}
// --- Duplicates ---
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct DuplicateGroupResponse {
pub group_id: Uuid,
pub detection_method: String,
pub status: String,
pub candidates: Vec<DuplicateCandidateResponse>,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct DuplicateCandidateResponse {
pub asset_id: Uuid,
pub similarity_score: f64,
}
impl DuplicateGroupResponse {
pub fn from_domain(group: &domain::entities::DuplicateGroup) -> Self {
Self {
group_id: *group.group_id.as_uuid(),
detection_method: format!("{:?}", group.detection_method),
status: format!("{:?}", group.status),
candidates: group
.candidates
.iter()
.map(|c| DuplicateCandidateResponse {
asset_id: *c.asset_id.as_uuid(),
similarity_score: c.similarity_score,
})
.collect(),
}
}
}
// --- Processing --- // --- Processing ---
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
@@ -276,6 +364,7 @@ pub struct JobResponse {
pub status: String, pub status: String,
pub priority: u32, pub priority: u32,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub error_message: Option<String>,
} }
impl JobResponse { impl JobResponse {
@@ -286,10 +375,17 @@ impl JobResponse {
status: format!("{:?}", job.status), status: format!("{:?}", job.status),
priority: job.priority, priority: job.priority,
created_at: *job.created_at.as_datetime(), created_at: *job.created_at.as_datetime(),
error_message: job.error_message.clone(),
} }
} }
} }
#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct JobListResponse {
pub jobs: Vec<JobResponse>,
pub total: u64,
}
#[derive(Debug, serde::Serialize, utoipa::ToSchema)] #[derive(Debug, serde::Serialize, utoipa::ToSchema)]
pub struct BatchProgressResponse { pub struct BatchProgressResponse {
pub batch_id: Uuid, pub batch_id: Uuid,

View File

@@ -14,6 +14,9 @@ bytes = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
futures = { workspace = true }
[dependencies.chrono]
workspace = true
[dev-dependencies] [dev-dependencies]
chrono = { workspace = true }

View File

@@ -0,0 +1,81 @@
use std::sync::Arc;
use domain::{
entities::{AssetStack, StackMemberRole, StackType},
errors::DomainError,
ports::{AssetRepository, AssetStackRepository},
value_objects::SystemId,
};
pub struct CreateStackCommand {
pub stack_type: StackType,
pub primary_asset_id: SystemId,
pub additional_asset_ids: Vec<(SystemId, StackMemberRole)>,
pub owner_id: SystemId,
}
pub struct CreateStackHandler {
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
}
impl CreateStackHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
) -> Self {
Self {
asset_repo,
stack_repo,
}
}
pub async fn execute(&self, cmd: CreateStackCommand) -> Result<AssetStack, DomainError> {
self.asset_repo
.find_by_id(&cmd.primary_asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Primary asset not found".into()))?;
let mut stack = AssetStack::new(cmd.stack_type, cmd.primary_asset_id, cmd.owner_id);
for (asset_id, role) in cmd.additional_asset_ids {
self.asset_repo
.find_by_id(&asset_id)
.await?
.ok_or_else(|| {
DomainError::NotFound(format!("Asset {} not found", asset_id.as_uuid()))
})?;
stack.add_member(asset_id, role)?;
}
self.stack_repo.save(&stack).await?;
Ok(stack)
}
}
pub struct DeleteStackCommand {
pub stack_id: SystemId,
pub caller_id: SystemId,
}
pub struct DeleteStackHandler {
stack_repo: Arc<dyn AssetStackRepository>,
}
impl DeleteStackHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, cmd: DeleteStackCommand) -> Result<(), DomainError> {
let stack = self
.stack_repo
.find_by_id(&cmd.stack_id)
.await?
.ok_or_else(|| DomainError::NotFound("Stack not found".into()))?;
if stack.owner_user_id != cmd.caller_id {
return Err(DomainError::Forbidden("Not your stack".into()));
}
self.stack_repo.delete(&cmd.stack_id).await
}
}

View File

@@ -0,0 +1,128 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
AssetRepository, DerivativeRepository, EventPublisher, FileStoragePort, SidecarRepository,
StorageVolumeRepository,
},
value_objects::{DateTimeStamp, SystemId},
};
pub struct DeleteAssetCommand {
pub asset_id: SystemId,
pub deleted_by: SystemId,
}
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>,
event_publisher: Arc<dyn EventPublisher>,
}
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>,
event_publisher: Arc<dyn EventPublisher>,
) -> Self {
Self {
asset_repo,
volume_repo,
derivative_repo,
sidecar_repo,
file_storage,
event_publisher,
}
}
pub async fn execute(&self, cmd: DeleteAssetCommand) -> Result<(), DomainError> {
let asset = self
.asset_repo
.find_by_id(&cmd.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
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?;
}
self.event_publisher
.publish(&DomainEvent::AssetDeleted {
asset_id: cmd.asset_id,
deleted_by: cmd.deleted_by,
timestamp: DateTimeStamp::now(),
})
.await?;
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(())
}
}

View File

@@ -0,0 +1,87 @@
use std::collections::HashMap;
use std::sync::Arc;
use domain::{
entities::{AssetStack, AssetType, StackMemberRole, StackType},
errors::DomainError,
ports::{AssetRepository, AssetStackRepository},
value_objects::SystemId,
};
pub struct DetectLivePhotosCommand {
pub owner_id: SystemId,
}
pub struct DetectLivePhotosHandler {
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
}
impl DetectLivePhotosHandler {
pub fn new(
asset_repo: Arc<dyn AssetRepository>,
stack_repo: Arc<dyn AssetStackRepository>,
) -> Self {
Self {
asset_repo,
stack_repo,
}
}
pub async fn execute(
&self,
cmd: DetectLivePhotosCommand,
) -> Result<Vec<AssetStack>, DomainError> {
let assets = self
.asset_repo
.find_by_owner(&cmd.owner_id, 10_000, 0)
.await?;
let mut by_basename: HashMap<String, Vec<(SystemId, AssetType, String)>> = HashMap::new();
for asset in &assets {
let path = &asset.source_reference.relative_path;
if let Some(stem) = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
{
let key = stem.to_lowercase();
by_basename.entry(key).or_default().push((
asset.asset_id,
asset.asset_type,
asset.mime_type.clone(),
));
}
}
let mut created = Vec::new();
for group in by_basename.values() {
if group.len() < 2 {
continue;
}
let image = group.iter().find(|(_, t, _)| *t == AssetType::Image);
let video = group
.iter()
.find(|(_, t, m)| *t == AssetType::Video || m.starts_with("video/"));
if let (Some((img_id, _, _)), Some((vid_id, _, _))) = (image, video) {
let existing = self.stack_repo.find_by_asset(img_id).await?;
if existing
.iter()
.any(|s| s.stack_type == StackType::LivePhoto)
{
continue;
}
let mut stack = AssetStack::new(StackType::LivePhoto, *img_id, cmd.owner_id);
stack.add_member(*vid_id, StackMemberRole::MotionClip)?;
self.stack_repo.save(&stack).await?;
created.push(stack);
}
}
Ok(created)
}
}

View File

@@ -1,2 +1,7 @@
pub mod create_stack;
pub mod delete_asset;
pub mod restore_asset;
pub mod detect_live_photos;
pub mod register_asset; pub mod register_asset;
pub mod resolve_duplicate;
pub mod update_metadata; pub mod update_metadata;

View File

@@ -69,7 +69,7 @@ impl RegisterAssetHandler {
}; };
self.event_pub self.event_pub
.publish(DomainEvent::AssetIngested { .publish(&DomainEvent::AssetIngested {
asset_id: asset.asset_id, asset_id: asset.asset_id,
owner_user_id: asset.owner_user_id, owner_user_id: asset.owner_user_id,
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),

View File

@@ -0,0 +1,91 @@
use std::sync::Arc;
use domain::{errors::DomainError, ports::DuplicateRepository, value_objects::SystemId};
use super::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
pub struct ResolveDuplicateCommand {
pub group_id: SystemId,
pub keep_asset_id: SystemId,
pub resolved_by: SystemId,
}
pub struct ResolveDuplicateHandler {
duplicate_repo: Arc<dyn DuplicateRepository>,
delete_handler: Arc<DeleteAssetHandler>,
}
impl ResolveDuplicateHandler {
pub fn new(
duplicate_repo: Arc<dyn DuplicateRepository>,
delete_handler: Arc<DeleteAssetHandler>,
) -> Self {
Self {
duplicate_repo,
delete_handler,
}
}
pub async fn execute(&self, cmd: ResolveDuplicateCommand) -> Result<(), DomainError> {
let mut group = self
.duplicate_repo
.find_by_id(&cmd.group_id)
.await?
.ok_or_else(|| DomainError::NotFound("Duplicate group not found".into()))?;
if !group
.candidates
.iter()
.any(|c| c.asset_id == cmd.keep_asset_id)
{
return Err(DomainError::Validation(
"keep_asset_id not in duplicate group".into(),
));
}
let to_delete: Vec<SystemId> = group
.candidates
.iter()
.filter(|c| c.asset_id != cmd.keep_asset_id)
.map(|c| c.asset_id)
.collect();
for asset_id in to_delete {
self.delete_handler
.execute(DeleteAssetCommand {
asset_id,
deleted_by: cmd.resolved_by,
})
.await?;
}
group.resolve();
self.duplicate_repo.save(&group).await?;
Ok(())
}
}
pub struct ListDuplicatesQuery {
pub limit: u32,
pub offset: u32,
}
pub struct ListDuplicatesHandler {
duplicate_repo: Arc<dyn DuplicateRepository>,
}
impl ListDuplicatesHandler {
pub fn new(duplicate_repo: Arc<dyn DuplicateRepository>) -> Self {
Self { duplicate_repo }
}
pub async fn execute(
&self,
query: ListDuplicatesQuery,
) -> Result<Vec<domain::entities::DuplicateGroup>, DomainError> {
self.duplicate_repo
.find_unresolved(query.limit, query.offset)
.await
}
}

View 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
}
}

View File

@@ -43,7 +43,7 @@ impl UpdateMetadataHandler {
self.metadata_repo.save(&metadata).await?; self.metadata_repo.save(&metadata).await?;
self.event_pub self.event_pub
.publish(DomainEvent::MetadataUpdated { .publish(&DomainEvent::MetadataUpdated {
asset_id: cmd.asset_id, asset_id: cmd.asset_id,
updated_by: cmd.user_id, updated_by: cmd.user_id,
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),

View File

@@ -1,8 +1,27 @@
pub mod commands; pub mod commands;
pub mod queries; pub mod queries;
pub mod visibility;
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::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
pub use commands::resolve_duplicate::{
ListDuplicatesHandler, ListDuplicatesQuery, ResolveDuplicateCommand, ResolveDuplicateHandler,
};
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler}; pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery}; pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery}; pub use queries::get_date_summary::{DateSummaryEntry, GetDateSummaryHandler, GetDateSummaryQuery};
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_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
pub use queries::read_derivative::{
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
};
pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery, SearchResult};
pub use visibility::VisibilityFilteredAssetRepository;

View File

@@ -1,8 +1,9 @@
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
use domain::{ use domain::{
catalog::entities::Asset, catalog::entities::Asset,
catalog::services::resolve_metadata, catalog::services::resolve_metadata,
errors::DomainError, errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository}, ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
value_objects::{StructuredData, SystemId}, value_objects::{StructuredData, SystemId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -16,6 +17,7 @@ pub struct GetAssetQuery {
pub struct GetAssetHandler { pub struct GetAssetHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
metadata_repo: Arc<dyn AssetMetadataRepository>, metadata_repo: Arc<dyn AssetMetadataRepository>,
share_repo: Option<Arc<dyn ShareRepository>>,
} }
impl GetAssetHandler { impl GetAssetHandler {
@@ -26,6 +28,28 @@ impl GetAssetHandler {
Self { Self {
asset_repo, asset_repo,
metadata_repo, metadata_repo,
share_repo: None,
}
}
/// Enable sharing-aware visibility filtering. When set, the handler
/// wraps the inner `AssetRepository` with a `VisibilityFilteredAssetRepository`
/// so that shared assets are visible to the caller.
pub fn with_visibility_filter(mut self, share_repo: Arc<dyn ShareRepository>) -> Self {
self.share_repo = Some(share_repo);
self
}
/// Returns the effective asset repo — wrapped with a visibility filter
/// when a `ShareRepository` has been configured, otherwise the raw inner repo.
fn effective_repo(&self, caller_id: SystemId) -> Arc<dyn AssetRepository> {
match &self.share_repo {
Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new(
self.asset_repo.clone(),
share_repo.clone(),
caller_id,
)),
None => self.asset_repo.clone(),
} }
} }
@@ -33,13 +57,16 @@ impl GetAssetHandler {
&self, &self,
query: GetAssetQuery, query: GetAssetQuery,
) -> Result<(Asset, StructuredData), DomainError> { ) -> Result<(Asset, StructuredData), DomainError> {
let asset = self let repo = self.effective_repo(query.user_id);
.asset_repo
let asset = repo
.find_by_id(&query.asset_id) .find_by_id(&query.asset_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?;
if asset.owner_user_id != query.user_id { // When the visibility filter is active it already enforces access.
// When it is not, fall back to the original owner-only check.
if self.share_repo.is_none() && asset.owner_user_id != query.user_id {
return Err(DomainError::Forbidden("Access denied".to_string())); return Err(DomainError::Forbidden("Access denied".to_string()));
} }

View File

@@ -0,0 +1,32 @@
use domain::{errors::DomainError, ports::AssetRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct GetDateSummaryQuery {
pub owner_id: SystemId,
}
pub struct DateSummaryEntry {
pub date: chrono::NaiveDate,
pub count: u64,
}
pub struct GetDateSummaryHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl GetDateSummaryHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(
&self,
query: GetDateSummaryQuery,
) -> Result<Vec<DateSummaryEntry>, DomainError> {
let rows = self.asset_repo.date_summary(&query.owner_id).await?;
Ok(rows
.into_iter()
.map(|(date, count)| DateSummaryEntry { date, count })
.collect())
}
}

View File

@@ -0,0 +1,31 @@
use domain::{
entities::AssetStack, errors::DomainError, ports::AssetStackRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct GetStackQuery {
pub stack_id: SystemId,
pub caller_id: SystemId,
}
pub struct GetStackHandler {
stack_repo: Arc<dyn AssetStackRepository>,
}
impl GetStackHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, query: GetStackQuery) -> Result<AssetStack, DomainError> {
let stack = self
.stack_repo
.find_by_id(&query.stack_id)
.await?
.ok_or_else(|| DomainError::NotFound("Stack not found".into()))?;
if stack.owner_user_id != query.caller_id {
return Err(DomainError::Forbidden("Not your stack".into()));
}
Ok(stack)
}
}

View File

@@ -1,8 +1,9 @@
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
use domain::{ use domain::{
catalog::entities::Asset, catalog::entities::Asset,
catalog::services::resolve_metadata, catalog::services::resolve_metadata,
errors::DomainError, errors::DomainError,
ports::{AssetMetadataRepository, AssetRepository}, ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
value_objects::{StructuredData, SystemId}, value_objects::{StructuredData, SystemId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -10,13 +11,20 @@ use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GetTimelineQuery { pub struct GetTimelineQuery {
pub owner_id: SystemId, pub owner_id: SystemId,
pub caller_id: Option<SystemId>,
pub limit: u32, pub limit: u32,
pub offset: u32, pub offset: u32,
} }
pub struct TimelineResult {
pub items: Vec<(Asset, StructuredData)>,
pub total: u64,
}
pub struct GetTimelineHandler { pub struct GetTimelineHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
metadata_repo: Arc<dyn AssetMetadataRepository>, metadata_repo: Arc<dyn AssetMetadataRepository>,
share_repo: Option<Arc<dyn ShareRepository>>,
} }
impl GetTimelineHandler { impl GetTimelineHandler {
@@ -27,25 +35,52 @@ impl GetTimelineHandler {
Self { Self {
asset_repo, asset_repo,
metadata_repo, metadata_repo,
share_repo: None,
} }
} }
pub async fn execute( /// Enable sharing-aware visibility filtering on timeline queries.
&self, pub fn with_visibility_filter(mut self, share_repo: Arc<dyn ShareRepository>) -> Self {
query: GetTimelineQuery, self.share_repo = Some(share_repo);
) -> Result<Vec<(Asset, StructuredData)>, DomainError> { self
let assets = self }
.asset_repo
fn effective_repo(&self, caller_id: SystemId) -> Arc<dyn AssetRepository> {
match &self.share_repo {
Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new(
self.asset_repo.clone(),
share_repo.clone(),
caller_id,
)),
None => self.asset_repo.clone(),
}
}
pub async fn execute(&self, query: GetTimelineQuery) -> Result<TimelineResult, DomainError> {
let caller_id = query.caller_id.unwrap_or(query.owner_id);
let repo = self.effective_repo(caller_id);
let total = repo.count_by_owner(&query.owner_id).await?;
let assets = repo
.find_by_owner(&query.owner_id, query.limit, query.offset) .find_by_owner(&query.owner_id, query.limit, query.offset)
.await?; .await?;
let mut results = Vec::with_capacity(assets.len()); let asset_ids: Vec<SystemId> = assets.iter().map(|a| a.asset_id).collect();
for asset in assets { let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?;
let layers = self.metadata_repo.find_by_asset(&asset.asset_id).await?;
let resolved = resolve_metadata(&layers);
results.push((asset, resolved));
}
Ok(results) let items = assets
.into_iter()
.map(|asset| {
let layers: Vec<_> = all_layers
.iter()
.filter(|m| m.asset_id == asset.asset_id)
.cloned()
.collect();
let resolved = resolve_metadata(&layers);
(asset, resolved)
})
.collect();
Ok(TimelineResult { items, total })
} }
} }

View File

@@ -0,0 +1,22 @@
use domain::{
entities::AssetStack, errors::DomainError, ports::AssetStackRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct ListStacksQuery {
pub owner_id: SystemId,
}
pub struct ListStacksHandler {
stack_repo: Arc<dyn AssetStackRepository>,
}
impl ListStacksHandler {
pub fn new(stack_repo: Arc<dyn AssetStackRepository>) -> Self {
Self { stack_repo }
}
pub async fn execute(&self, query: ListStacksQuery) -> Result<Vec<AssetStack>, DomainError> {
self.stack_repo.find_by_owner(&query.owner_id).await
}
}

View 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 })
}
}

View File

@@ -1,3 +1,9 @@
pub mod get_asset; pub mod get_asset;
pub mod get_date_summary;
pub mod list_trash;
pub mod get_stack;
pub mod get_timeline; pub mod get_timeline;
pub mod list_stacks;
pub mod read_asset_file; pub mod read_asset_file;
pub mod read_derivative;
pub mod search_assets;

View File

@@ -1,7 +1,6 @@
use bytes::Bytes;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{AssetRepository, FileStoragePort}, ports::{AssetRepository, DataStream, VolumeFileResolver},
value_objects::SystemId, value_objects::SystemId,
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -9,27 +8,29 @@ use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ReadAssetFileQuery { pub struct ReadAssetFileQuery {
pub asset_id: SystemId, pub asset_id: SystemId,
pub caller_id: SystemId,
} }
pub struct AssetFileResult { pub struct AssetFileResult {
pub data: Bytes, pub stream: DataStream,
pub size: u64,
pub mime_type: String, pub mime_type: String,
pub filename: String, pub filename: String,
} }
pub struct ReadAssetFileHandler { pub struct ReadAssetFileHandler {
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>, volume_resolver: Arc<dyn VolumeFileResolver>,
} }
impl ReadAssetFileHandler { impl ReadAssetFileHandler {
pub fn new( pub fn new(
asset_repo: Arc<dyn AssetRepository>, asset_repo: Arc<dyn AssetRepository>,
file_storage: Arc<dyn FileStoragePort>, volume_resolver: Arc<dyn VolumeFileResolver>,
) -> Self { ) -> Self {
Self { Self {
asset_repo, asset_repo,
file_storage, volume_resolver,
} }
} }
@@ -40,9 +41,16 @@ impl ReadAssetFileHandler {
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?; .ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?;
let data = self if asset.owner_user_id != query.caller_id {
.file_storage return Err(DomainError::Forbidden("Access denied".into()));
.read_file(&asset.source_reference.relative_path) }
let (stream, size) = self
.volume_resolver
.open_by_volume(
&asset.source_reference.volume_id,
&asset.source_reference.relative_path,
)
.await?; .await?;
let filename = asset let filename = asset
@@ -54,7 +62,8 @@ impl ReadAssetFileHandler {
.to_string(); .to_string();
Ok(AssetFileResult { Ok(AssetFileResult {
data, stream,
size,
mime_type: asset.mime_type, mime_type: asset.mime_type,
filename, filename,
}) })

View File

@@ -0,0 +1,82 @@
use domain::{
entities::{DerivativeProfile, GenerationStatus},
errors::DomainError,
ports::{DataStream, DerivativeRepository, FileStoragePort},
value_objects::SystemId,
};
use std::sync::Arc;
pub struct ReadDerivativeQuery {
pub asset_id: SystemId,
pub profile: DerivativeProfile,
pub caller_id: SystemId,
}
pub struct DerivativeFileResult {
pub stream: DataStream,
pub size: u64,
pub mime_type: String,
}
pub struct ReadDerivativeHandler {
derivative_repo: Arc<dyn DerivativeRepository>,
asset_repo: Arc<dyn domain::ports::AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
}
impl ReadDerivativeHandler {
pub fn new(
derivative_repo: Arc<dyn DerivativeRepository>,
asset_repo: Arc<dyn domain::ports::AssetRepository>,
file_storage: Arc<dyn FileStoragePort>,
) -> Self {
Self {
derivative_repo,
asset_repo,
file_storage,
}
}
pub async fn execute(
&self,
query: ReadDerivativeQuery,
) -> Result<DerivativeFileResult, DomainError> {
let asset = self
.asset_repo
.find_by_id(&query.asset_id)
.await?
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
if asset.owner_user_id != query.caller_id {
return Err(DomainError::Forbidden("Access denied".into()));
}
let derivative = self
.derivative_repo
.find_by_asset_and_profile(&query.asset_id, query.profile)
.await?
.ok_or_else(|| {
DomainError::NotFound(format!(
"Derivative {:?} not found for asset {}",
query.profile, query.asset_id
))
})?;
if derivative.generation_status != GenerationStatus::Ready {
return Err(DomainError::NotFound(format!(
"Derivative {:?} not ready for asset {}",
query.profile, query.asset_id
)));
}
let (stream, size) = self
.file_storage
.open_file(&derivative.storage_path)
.await?;
Ok(DerivativeFileResult {
stream,
size,
mime_type: derivative.mime_type,
})
}
}

View File

@@ -0,0 +1,42 @@
use std::sync::Arc;
use domain::{
entities::{Asset, AssetFilters},
errors::DomainError,
ports::AssetRepository,
value_objects::SystemId,
};
pub struct SearchAssetsQuery {
pub owner_id: SystemId,
pub filters: AssetFilters,
pub limit: u32,
pub offset: u32,
}
pub struct SearchResult {
pub items: Vec<Asset>,
pub total: u64,
}
pub struct SearchAssetsHandler {
asset_repo: Arc<dyn AssetRepository>,
}
impl SearchAssetsHandler {
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
Self { asset_repo }
}
pub async fn execute(&self, query: SearchAssetsQuery) -> Result<SearchResult, DomainError> {
let total = self
.asset_repo
.count_search(&query.owner_id, &query.filters)
.await?;
let items = self
.asset_repo
.search(&query.owner_id, &query.filters, query.limit, query.offset)
.await?;
Ok(SearchResult { items, total })
}
}

View File

@@ -0,0 +1,323 @@
use async_trait::async_trait;
use domain::{
catalog::entities::{Asset, AssetFilters},
errors::DomainError,
ports::{AssetRepository, ShareRepository},
sharing::entities::ShareTarget,
value_objects::{Checksum, SystemId},
};
use std::sync::Arc;
use tokio::sync::OnceCell;
pub struct VisibilityFilteredAssetRepository {
inner: Arc<dyn AssetRepository>,
share_repo: Arc<dyn ShareRepository>,
caller_id: SystemId,
caller_targets: OnceCell<Vec<ShareTarget>>,
}
impl VisibilityFilteredAssetRepository {
pub fn new(
inner: Arc<dyn AssetRepository>,
share_repo: Arc<dyn ShareRepository>,
caller_id: SystemId,
) -> Self {
Self {
inner,
share_repo,
caller_id,
caller_targets: OnceCell::new(),
}
}
async fn get_caller_targets(&self) -> Result<&[ShareTarget], DomainError> {
self.caller_targets
.get_or_try_init(|| async {
self.share_repo.find_targets_for_user(&self.caller_id).await
})
.await
.map(|v| v.as_slice())
}
async fn caller_can_access(&self, asset: &Asset) -> Result<bool, DomainError> {
if asset.owner_user_id == self.caller_id {
return Ok(true);
}
let scopes = self
.share_repo
.find_scopes_for_resource(&asset.asset_id)
.await?;
if scopes.is_empty() {
return Ok(false);
}
let caller_targets = self.get_caller_targets().await?;
for scope in &scopes {
if scope.is_expired() {
continue;
}
if caller_targets.iter().any(|t| t.scope_id == scope.scope_id) {
return Ok(true);
}
}
Ok(false)
}
async fn filter_visible(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, DomainError> {
let mut visible = Vec::with_capacity(assets.len());
for asset in assets {
if self.caller_can_access(&asset).await? {
visible.push(asset);
}
}
Ok(visible)
}
}
#[async_trait]
impl AssetRepository for VisibilityFilteredAssetRepository {
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
let asset = self.inner.find_by_id(id).await?;
match asset {
Some(a) if self.caller_can_access(&a).await? => Ok(Some(a)),
_ => Ok(None),
}
}
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> {
let assets = self.inner.find_by_checksum(checksum).await?;
self.filter_visible(assets).await
}
async fn find_by_owner(
&self,
owner_id: &SystemId,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
if owner_id == &self.caller_id {
return self.inner.find_by_owner(owner_id, limit, offset).await;
}
let assets = self.inner.find_by_owner(owner_id, limit, offset).await?;
self.filter_visible(assets).await
}
async fn search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
limit: u32,
offset: u32,
) -> Result<Vec<Asset>, DomainError> {
if owner_id == &self.caller_id {
return self.inner.search(owner_id, filters, limit, offset).await;
}
let assets = self.inner.search(owner_id, filters, limit, offset).await?;
self.filter_visible(assets).await
}
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
self.inner.count_by_owner(owner_id).await
}
async fn count_search(
&self,
owner_id: &SystemId,
filters: &AssetFilters,
) -> Result<u64, DomainError> {
self.inner.count_search(owner_id, filters).await
}
async fn date_summary(
&self,
owner_id: &SystemId,
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
self.inner.date_summary(owner_id).await
}
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
self.inner.save(asset).await
}
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)]
mod tests {
use super::*;
use crate::testing::{InMemoryAssetRepository, InMemoryShareRepository};
use domain::{
catalog::entities::{AssetType, SourceReference},
sharing::entities::{ScopeType, ShareScope, ShareTarget, ShareableType, TargetType},
value_objects::{Checksum, SystemId},
};
fn make_asset(owner: SystemId) -> Asset {
Asset::new(
SourceReference {
volume_id: SystemId::new(),
relative_path: "test/photo.jpg".to_string(),
checksum: Checksum::new("a".repeat(64)).unwrap(),
},
AssetType::Image,
"image/jpeg",
1024,
owner,
)
}
fn share_asset(asset_id: SystemId, granter: SystemId) -> ShareScope {
ShareScope::new(ScopeType::User, ShareableType::Asset, asset_id, granter)
}
fn target_user(scope_id: SystemId, user_id: SystemId) -> ShareTarget {
ShareTarget::new(scope_id, TargetType::User, user_id, SystemId::new())
}
#[tokio::test]
async fn owner_can_always_see_own_asset() {
let owner_id = SystemId::new();
let asset = make_asset(owner_id);
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), owner_id);
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().asset_id, asset.asset_id);
}
#[tokio::test]
async fn stranger_cannot_see_unshared_asset() {
let owner_id = SystemId::new();
let stranger_id = SystemId::new();
let asset = make_asset(owner_id);
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn shared_user_can_see_asset() {
let owner_id = SystemId::new();
let friend_id = SystemId::new();
let asset = make_asset(owner_id);
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let scope = share_asset(asset.asset_id, owner_id);
share_repo.save_scope(&scope).await.unwrap();
let target = target_user(scope.scope_id, friend_id);
share_repo.save_target(&target).await.unwrap();
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), friend_id);
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().asset_id, asset.asset_id);
}
#[tokio::test]
async fn find_by_checksum_filters_inaccessible() {
let owner_id = SystemId::new();
let stranger_id = SystemId::new();
let asset_a = make_asset(owner_id);
let mut asset_b = make_asset(stranger_id);
asset_b.source_reference.checksum = asset_a.source_reference.checksum.clone();
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset_a).await.unwrap();
inner.save(&asset_b).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
let results = filtered
.find_by_checksum(&asset_a.source_reference.checksum)
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].owner_user_id, stranger_id);
}
#[tokio::test]
async fn find_by_owner_skips_filter_for_own_assets() {
let owner_id = SystemId::new();
let asset = make_asset(owner_id);
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), owner_id);
let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap();
assert_eq!(results.len(), 1);
}
#[tokio::test]
async fn find_by_owner_filters_others_assets() {
let owner_id = SystemId::new();
let stranger_id = SystemId::new();
let asset = make_asset(owner_id);
let inner = Arc::new(InMemoryAssetRepository::new());
inner.save(&asset).await.unwrap();
let share_repo = Arc::new(InMemoryShareRepository::new());
let filtered =
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap();
assert!(results.is_empty());
}
}

View File

@@ -1,9 +1,10 @@
use domain::{ use domain::{
entities::User, entities::{RefreshToken, User},
errors::DomainError, errors::DomainError,
ports::{PasswordHasher, TokenIssuer, UserRepository}, ports::{PasswordHasher, RefreshTokenRepository, TokenIssuer, UserRepository},
value_objects::Email, value_objects::{DateTimeStamp, Email},
}; };
use sha2::{Digest, Sha256};
use std::sync::Arc; use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -16,6 +17,7 @@ pub struct LoginUserHandler {
repo: Arc<dyn UserRepository>, repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>, hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>, issuer: Arc<dyn TokenIssuer>,
refresh_repo: Arc<dyn RefreshTokenRepository>,
} }
impl LoginUserHandler { impl LoginUserHandler {
@@ -23,15 +25,20 @@ impl LoginUserHandler {
repo: Arc<dyn UserRepository>, repo: Arc<dyn UserRepository>,
hasher: Arc<dyn PasswordHasher>, hasher: Arc<dyn PasswordHasher>,
issuer: Arc<dyn TokenIssuer>, issuer: Arc<dyn TokenIssuer>,
refresh_repo: Arc<dyn RefreshTokenRepository>,
) -> Self { ) -> Self {
Self { Self {
repo, repo,
hasher, hasher,
issuer, issuer,
refresh_repo,
} }
} }
pub async fn execute(&self, cmd: LoginUserCommand) -> Result<(User, String), DomainError> { pub async fn execute(
&self,
cmd: LoginUserCommand,
) -> Result<(User, String, String), DomainError> {
let email = Email::new(&cmd.email)?; let email = Email::new(&cmd.email)?;
let user = self let user = self
.repo .repo
@@ -45,7 +52,21 @@ impl LoginUserHandler {
if !valid { if !valid {
return Err(DomainError::Unauthorized("Invalid credentials".to_string())); return Err(DomainError::Unauthorized("Invalid credentials".to_string()));
} }
let token = self.issuer.issue(&user.id, "user").await?; let access_token = self.issuer.issue(&user.id, &user.role).await?;
Ok((user, token)) let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
Ok((user, access_token, raw_refresh))
} }
} }
pub async fn generate_refresh_token(
repo: &Arc<dyn RefreshTokenRepository>,
user_id: &domain::value_objects::SystemId,
) -> Result<(String, domain::value_objects::SystemId), DomainError> {
let raw = uuid::Uuid::new_v4().to_string();
let hash = format!("{:x}", Sha256::digest(raw.as_bytes()));
let expires_at = DateTimeStamp::from_datetime(chrono::Utc::now() + chrono::Duration::days(30));
let token = RefreshToken::new(*user_id, hash, expires_at);
let token_id = token.token_id;
repo.save(&token).await?;
Ok((raw, token_id))
}

View File

@@ -0,0 +1,16 @@
use domain::{errors::DomainError, ports::RefreshTokenRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct LogoutHandler {
refresh_repo: Arc<dyn RefreshTokenRepository>,
}
impl LogoutHandler {
pub fn new(refresh_repo: Arc<dyn RefreshTokenRepository>) -> Self {
Self { refresh_repo }
}
pub async fn execute(&self, user_id: &SystemId) -> Result<(), DomainError> {
self.refresh_repo.delete_by_user(user_id).await
}
}

View File

@@ -1,5 +1,9 @@
pub mod login_user; pub mod login_user;
pub mod logout;
pub mod refresh_token;
pub mod register_user; pub mod register_user;
pub use login_user::{LoginUserCommand, LoginUserHandler}; pub use login_user::{LoginUserCommand, LoginUserHandler};
pub use logout::LogoutHandler;
pub use refresh_token::{RefreshTokenCommand, RefreshTokenHandler};
pub use register_user::{RegisterUserCommand, RegisterUserHandler}; pub use register_user::{RegisterUserCommand, RegisterUserHandler};

View File

@@ -0,0 +1,61 @@
use super::login_user::generate_refresh_token;
use domain::{
errors::DomainError,
ports::{RefreshTokenRepository, TokenIssuer, UserRepository},
};
use sha2::{Digest, Sha256};
use std::sync::Arc;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RefreshTokenCommand {
pub refresh_token: String,
}
pub struct RefreshTokenHandler {
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
}
impl RefreshTokenHandler {
pub fn new(
refresh_repo: Arc<dyn RefreshTokenRepository>,
user_repo: Arc<dyn UserRepository>,
issuer: Arc<dyn TokenIssuer>,
) -> Self {
Self {
refresh_repo,
user_repo,
issuer,
}
}
pub async fn execute(&self, cmd: RefreshTokenCommand) -> Result<(String, String), DomainError> {
let hash = format!("{:x}", Sha256::digest(cmd.refresh_token.as_bytes()));
let token = self
.refresh_repo
.find_by_hash(&hash)
.await?
.ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".to_string()))?;
if !token.is_valid() {
return Err(DomainError::Unauthorized(
"Refresh token expired or revoked".to_string(),
));
}
let user = self
.user_repo
.find_by_id(&token.user_id)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".to_string()))?;
self.refresh_repo.delete(&token.token_id).await?;
let access_token = self.issuer.issue(&user.id, &user.role).await?;
let (raw_refresh, _) = generate_refresh_token(&self.refresh_repo, &user.id).await?;
Ok((access_token, raw_refresh))
}
}

View File

@@ -53,7 +53,11 @@ impl RegisterUserHandler {
))); )));
} }
let hash = self.hasher.hash(&cmd.password).await?; let hash = self.hasher.hash(&cmd.password).await?;
let user = User::new(&cmd.username, email, hash); let is_first = self.user_repo.count().await? == 0;
let mut user = User::new(&cmd.username, email, hash);
if is_first {
user.role = "admin".to_string();
}
self.user_repo.save(&user).await?; self.user_repo.save(&user).await?;
Ok(user) Ok(user)
} }

View File

@@ -1,5 +1,8 @@
pub mod commands; pub mod commands;
pub mod queries; pub mod queries;
pub use commands::{LoginUserCommand, LoginUserHandler, RegisterUserCommand, RegisterUserHandler}; pub use commands::{
LoginUserCommand, LoginUserHandler, LogoutHandler, RefreshTokenCommand, RefreshTokenHandler,
RegisterUserCommand, RegisterUserHandler, login_user::generate_refresh_token,
};
pub use queries::{GetProfileHandler, GetProfileQuery}; pub use queries::{GetProfileHandler, GetProfileQuery};

View 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
}
}

View File

@@ -1,7 +1,11 @@
pub mod create_album; pub mod create_album;
pub mod delete_album;
pub mod manage_album_entries; pub mod manage_album_entries;
pub mod tag_asset; pub mod tag_asset;
pub mod update_album;
pub use create_album::{CreateAlbumCommand, CreateAlbumHandler}; pub use create_album::{CreateAlbumCommand, CreateAlbumHandler};
pub use delete_album::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use manage_album_entries::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use tag_asset::{TagAssetCommand, TagAssetHandler}; pub use tag_asset::{TagAssetCommand, TagAssetHandler};
pub use update_album::{UpdateAlbumCommand, UpdateAlbumHandler};

View File

@@ -0,0 +1,44 @@
use domain::{errors::DomainError, ports::AlbumRepository, value_objects::SystemId};
use std::sync::Arc;
pub struct UpdateAlbumCommand {
pub album_id: SystemId,
pub user_id: SystemId,
pub title: Option<String>,
pub description: Option<String>,
}
pub struct UpdateAlbumHandler {
album_repo: Arc<dyn AlbumRepository>,
}
impl UpdateAlbumHandler {
pub fn new(album_repo: Arc<dyn AlbumRepository>) -> Self {
Self { album_repo }
}
pub async fn execute(
&self,
cmd: UpdateAlbumCommand,
) -> Result<domain::entities::Album, DomainError> {
let mut album = self
.album_repo
.find_by_id(&cmd.album_id)
.await?
.ok_or_else(|| DomainError::NotFound("Album not found".into()))?;
if album.creator_user_id != cmd.user_id {
return Err(DomainError::Forbidden("Not your album".into()));
}
if let Some(title) = cmd.title {
album.title = title;
}
if let Some(desc) = cmd.description {
album.description = desc;
}
self.album_repo.save(&album).await?;
Ok(album)
}
}

View File

@@ -3,5 +3,8 @@ pub mod queries;
pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler}; pub use commands::{AlbumAction, ManageAlbumEntriesCommand, ManageAlbumEntriesHandler};
pub use commands::{CreateAlbumCommand, CreateAlbumHandler}; pub use commands::{CreateAlbumCommand, CreateAlbumHandler};
pub use commands::{DeleteAlbumCommand, DeleteAlbumHandler};
pub use commands::{TagAssetCommand, TagAssetHandler}; pub use commands::{TagAssetCommand, TagAssetHandler};
pub use commands::{UpdateAlbumCommand, UpdateAlbumHandler};
pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery}; pub use queries::get_album::{GetAlbumHandler, GetAlbumQuery};
pub use queries::list_albums::{ListAlbumsHandler, ListAlbumsQuery};

View File

@@ -0,0 +1,22 @@
use domain::{
entities::Album, errors::DomainError, ports::AlbumRepository, value_objects::SystemId,
};
use std::sync::Arc;
pub struct ListAlbumsQuery {
pub user_id: SystemId,
}
pub struct ListAlbumsHandler {
album_repo: Arc<dyn AlbumRepository>,
}
impl ListAlbumsHandler {
pub fn new(album_repo: Arc<dyn AlbumRepository>) -> Self {
Self { album_repo }
}
pub async fn execute(&self, query: ListAlbumsQuery) -> Result<Vec<Album>, DomainError> {
self.album_repo.find_by_creator(&query.user_id).await
}
}

View File

@@ -1 +1,2 @@
pub mod get_album; pub mod get_album;
pub mod list_albums;

View File

@@ -49,7 +49,7 @@ impl CompleteJobHandler {
self.batch_repo.save(&batch).await?; self.batch_repo.save(&batch).await?;
} }
self.event_pub self.event_pub
.publish(DomainEvent::JobCompleted { .publish(&DomainEvent::JobCompleted {
job_id: job.job_id, job_id: job.job_id,
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),
}) })

View File

@@ -39,7 +39,7 @@ impl EnqueueJobHandler {
} }
self.job_repo.save(&job).await?; self.job_repo.save(&job).await?;
self.event_pub self.event_pub
.publish(DomainEvent::JobEnqueued { .publish(&DomainEvent::JobEnqueued {
job_id: job.job_id, job_id: job.job_id,
job_type: format!("{:?}", cmd.job_type), job_type: format!("{:?}", cmd.job_type),
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),

View File

@@ -0,0 +1,178 @@
use domain::{
entities::{Job, JobType},
errors::DomainError,
events::DomainEvent,
ports::{
EventPublisher, JobBatchRepository, JobRepository, PipelineRepository, PluginRegistry,
PluginRepository,
},
value_objects::{DateTimeStamp, StructuredData, SystemId},
};
use std::sync::Arc;
pub struct ExecutePipelineCommand {
pub job_id: SystemId,
}
pub struct ExecutePipelineHandler {
job_repo: Arc<dyn JobRepository>,
batch_repo: Arc<dyn JobBatchRepository>,
pipeline_repo: Arc<dyn PipelineRepository>,
plugin_repo: Arc<dyn PluginRepository>,
plugin_registry: Arc<dyn PluginRegistry>,
event_pub: Arc<dyn EventPublisher>,
}
fn job_type_to_trigger(job_type: &JobType) -> &str {
match job_type {
JobType::ScanDirectory => "scan_directory",
JobType::ExtractMetadata => "extract_metadata",
JobType::GenerateDerivative => "generate_derivative",
JobType::SyncSidecar => "sync_sidecar",
JobType::DetectDuplicates => "detect_duplicates",
JobType::Custom(s) => s.as_str(),
}
}
impl ExecutePipelineHandler {
pub fn new(
job_repo: Arc<dyn JobRepository>,
batch_repo: Arc<dyn JobBatchRepository>,
pipeline_repo: Arc<dyn PipelineRepository>,
plugin_repo: Arc<dyn PluginRepository>,
plugin_registry: Arc<dyn PluginRegistry>,
event_pub: Arc<dyn EventPublisher>,
) -> Self {
Self {
job_repo,
batch_repo,
pipeline_repo,
plugin_repo,
plugin_registry,
event_pub,
}
}
pub async fn execute(&self, cmd: ExecutePipelineCommand) -> Result<Job, DomainError> {
let mut job = self
.job_repo
.find_by_id(&cmd.job_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Job {} not found", cmd.job_id)))?;
if job.status == domain::entities::JobStatus::Queued {
job.start()?;
self.job_repo.save(&job).await?;
}
let trigger = job_type_to_trigger(&job.job_type);
let pipelines = self.pipeline_repo.find_by_trigger(trigger).await?;
let result = match pipelines.first() {
Some(pipeline) => self.run_pipeline_steps(&job, pipeline).await,
None => self.run_direct(&job).await,
};
match result {
Ok(result_data) => {
job.complete(result_data);
self.job_repo.save(&job).await?;
self.update_batch_on_complete(&job).await?;
self.event_pub
.publish(&DomainEvent::JobCompleted {
job_id: job.job_id,
timestamp: DateTimeStamp::now(),
})
.await?;
}
Err(e) => {
let error_msg = e.to_string();
job.fail(&error_msg);
self.job_repo.save(&job).await?;
self.update_batch_on_fail(&job).await?;
self.event_pub
.publish(&DomainEvent::JobFailed {
job_id: job.job_id,
error: error_msg,
timestamp: DateTimeStamp::now(),
})
.await?;
}
}
Ok(job)
}
async fn run_pipeline_steps(
&self,
job: &Job,
pipeline: &domain::entities::ProcessingPipeline,
) -> Result<StructuredData, DomainError> {
let mut accumulated = StructuredData::new();
let mut sorted_steps = pipeline.steps.clone();
sorted_steps.sort_by_key(|s| s.step_order);
for step in &sorted_steps {
let plugin = self
.plugin_repo
.find_by_id(&step.plugin_id)
.await?
.ok_or_else(|| {
DomainError::NotFound(format!("Plugin {} not found", step.plugin_id))
})?;
if !plugin.is_enabled {
continue;
}
let executor = self
.plugin_registry
.get_executor(&plugin.name)
.ok_or_else(|| {
DomainError::NotFound(format!(
"No executor registered for plugin '{}'",
plugin.name
))
})?;
let step_result = executor
.execute(job.target_asset_id, &job.payload, &step.configuration)
.await?;
accumulated.merge_from(step_result);
}
Ok(accumulated)
}
async fn run_direct(&self, job: &Job) -> Result<StructuredData, DomainError> {
let trigger = job_type_to_trigger(&job.job_type);
let executor = self.plugin_registry.get_executor(trigger).ok_or_else(|| {
DomainError::NotFound(format!("No pipeline or executor found for '{}'", trigger))
})?;
executor
.execute(job.target_asset_id, &job.payload, &StructuredData::new())
.await
}
async fn update_batch_on_complete(&self, job: &Job) -> Result<(), DomainError> {
if let Some(ref batch_id) = job.batch_id
&& let Some(mut batch) = self.batch_repo.find_by_id(batch_id).await?
{
batch.record_completion();
self.batch_repo.save(&batch).await?;
}
Ok(())
}
async fn update_batch_on_fail(&self, job: &Job) -> Result<(), DomainError> {
if let Some(ref batch_id) = job.batch_id
&& let Some(mut batch) = self.batch_repo.find_by_id(batch_id).await?
{
batch.record_failure();
self.batch_repo.save(&batch).await?;
}
Ok(())
}
}

View File

@@ -49,7 +49,7 @@ impl FailJobHandler {
self.batch_repo.save(&batch).await?; self.batch_repo.save(&batch).await?;
} }
self.event_pub self.event_pub
.publish(DomainEvent::JobFailed { .publish(&DomainEvent::JobFailed {
job_id: job.job_id, job_id: job.job_id,
error: cmd.error, error: cmd.error,
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),
@@ -57,7 +57,7 @@ impl FailJobHandler {
.await?; .await?;
} else if job.status == JobStatus::Queued { } else if job.status == JobStatus::Queued {
self.event_pub self.event_pub
.publish(DomainEvent::JobEnqueued { .publish(&DomainEvent::JobEnqueued {
job_id: job.job_id, job_id: job.job_id,
job_type: format!("{:?}", job.job_type), job_type: format!("{:?}", job.job_type),
timestamp: DateTimeStamp::now(), timestamp: DateTimeStamp::now(),

View File

@@ -1,6 +1,8 @@
pub mod complete_job; pub mod complete_job;
pub mod configure_pipeline; pub mod configure_pipeline;
pub mod enqueue_job; pub mod enqueue_job;
pub mod execute_pipeline;
pub mod fail_job; pub mod fail_job;
pub mod manage_plugin; pub mod manage_plugin;
pub mod process_next_job;
pub mod start_job; pub mod start_job;

View File

@@ -0,0 +1,36 @@
use super::execute_pipeline::{ExecutePipelineCommand, ExecutePipelineHandler};
use domain::{entities::Job, errors::DomainError, ports::JobRepository};
use std::sync::Arc;
pub struct ProcessNextJobCommand;
pub struct ProcessNextJobHandler {
job_repo: Arc<dyn JobRepository>,
execute_pipeline: Arc<ExecutePipelineHandler>,
}
impl ProcessNextJobHandler {
pub fn new(
job_repo: Arc<dyn JobRepository>,
execute_pipeline: Arc<ExecutePipelineHandler>,
) -> Self {
Self {
job_repo,
execute_pipeline,
}
}
pub async fn execute(&self, _cmd: ProcessNextJobCommand) -> Result<Option<Job>, DomainError> {
let job = self.job_repo.find_next_queued().await?;
match job {
None => Ok(None),
Some(j) => {
let result = self
.execute_pipeline
.execute(ExecutePipelineCommand { job_id: j.job_id })
.await?;
Ok(Some(result))
}
}
}
}

View File

@@ -6,9 +6,14 @@ pub use commands::configure_pipeline::{
ConfigurePipelineCommand, ConfigurePipelineHandler, PipelineStepConfig, ConfigurePipelineCommand, ConfigurePipelineHandler, PipelineStepConfig,
}; };
pub use commands::enqueue_job::{EnqueueJobCommand, EnqueueJobHandler}; pub use commands::enqueue_job::{EnqueueJobCommand, EnqueueJobHandler};
pub use commands::execute_pipeline::{ExecutePipelineCommand, ExecutePipelineHandler};
pub use commands::fail_job::{FailJobCommand, FailJobHandler}; pub use commands::fail_job::{FailJobCommand, FailJobHandler};
pub use commands::manage_plugin::{ManagePluginCommand, ManagePluginHandler, PluginAction}; pub use commands::manage_plugin::{ManagePluginCommand, ManagePluginHandler, PluginAction};
pub use commands::process_next_job::{ProcessNextJobCommand, ProcessNextJobHandler};
pub use commands::start_job::{StartJobCommand, StartJobHandler}; pub use commands::start_job::{StartJobCommand, StartJobHandler};
pub use queries::list_jobs::{JobListResult, ListJobsHandler, ListJobsQuery};
pub use queries::list_pipelines::ListPipelinesHandler;
pub use queries::list_plugins::ListPluginsHandler;
pub use queries::report_batch_progress::{ pub use queries::report_batch_progress::{
BatchProgress, ReportBatchProgressHandler, ReportBatchProgressQuery, BatchProgress, ReportBatchProgressHandler, ReportBatchProgressQuery,
}; };

View File

@@ -0,0 +1,34 @@
use std::sync::Arc;
use domain::{entities::Job, errors::DomainError, ports::JobRepository};
pub struct ListJobsQuery {
pub status: Option<String>,
pub limit: u32,
pub offset: u32,
}
pub struct JobListResult {
pub jobs: Vec<Job>,
pub total: u64,
}
pub struct ListJobsHandler {
job_repo: Arc<dyn JobRepository>,
}
impl ListJobsHandler {
pub fn new(job_repo: Arc<dyn JobRepository>) -> Self {
Self { job_repo }
}
pub async fn execute(&self, query: ListJobsQuery) -> Result<JobListResult, DomainError> {
let status_ref = query.status.as_deref();
let jobs = self
.job_repo
.find_all(status_ref, query.limit, query.offset)
.await?;
let total = self.job_repo.count(status_ref).await?;
Ok(JobListResult { jobs, total })
}
}

View File

@@ -0,0 +1,18 @@
use domain::{
entities::ProcessingPipeline, errors::DomainError, ports::PipelineRepository,
};
use std::sync::Arc;
pub struct ListPipelinesHandler {
repo: Arc<dyn PipelineRepository>,
}
impl ListPipelinesHandler {
pub fn new(repo: Arc<dyn PipelineRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
self.repo.find_all().await
}
}

View File

@@ -0,0 +1,16 @@
use domain::{entities::Plugin, errors::DomainError, ports::PluginRepository};
use std::sync::Arc;
pub struct ListPluginsHandler {
repo: Arc<dyn PluginRepository>,
}
impl ListPluginsHandler {
pub fn new(repo: Arc<dyn PluginRepository>) -> Self {
Self { repo }
}
pub async fn execute(&self) -> Result<Vec<Plugin>, DomainError> {
self.repo.find_all().await
}
}

Some files were not shown because too many files have changed in this diff Show More