Compare commits
9 Commits
84fb410316
...
0077caa743
| Author | SHA1 | Date | |
|---|---|---|---|
| 0077caa743 | |||
| 957737ac9b | |||
| 49f77a78b9 | |||
| 6140ecd3ba | |||
| 7b5bb66b37 | |||
| bcaf49cc81 | |||
| d879fd6437 | |||
| 168f2a6a27 | |||
| c6f82090d2 |
28
.env.example
28
.env.example
@@ -7,36 +7,44 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Trash (default 30 days before permanent purge)
|
||||||
|
# ============================================================================
|
||||||
|
# TRASH_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================================
|
||||||
|
RUST_LOG=info
|
||||||
|
|||||||
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -100,6 +100,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"object_store",
|
"object_store",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -188,6 +189,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
@@ -815,6 +817,7 @@ dependencies = [
|
|||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"email_address",
|
||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -859,6 +862,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email_address"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -894,6 +906,16 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "etcetera"
|
name = "etcetera"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@@ -2979,6 +3001,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signal-hook-registry"
|
||||||
|
version = "1.4.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||||
|
dependencies = [
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "signatory"
|
name = "signatory"
|
||||||
version = "0.27.1"
|
version = "0.27.1"
|
||||||
@@ -3452,6 +3484,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
"tokio-macros",
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
@@ -4361,6 +4394,7 @@ dependencies = [
|
|||||||
"application",
|
"application",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
|
|||||||
@@ -20,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"
|
||||||
@@ -28,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"] }
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ COPY Cargo.toml Cargo.lock ./
|
|||||||
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
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-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/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/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/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/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/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||||
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||||
|
|||||||
121
README.md
121
README.md
@@ -10,6 +10,34 @@ Self-hosted media orchestrator and gallery. Alternative to Apple Photos, Google
|
|||||||
- **Modular** — core works without AI/ML. Face detection, classification, smart search are optional plugins.
|
- **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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ pub enum EventPayload {
|
|||||||
error: String,
|
error: String,
|
||||||
timestamp: 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 {
|
impl EventPayload {
|
||||||
@@ -69,6 +92,11 @@ impl EventPayload {
|
|||||||
Self::JobEnqueued { .. } => "jobs.enqueued",
|
Self::JobEnqueued { .. } => "jobs.enqueued",
|
||||||
Self::JobCompleted { .. } => "jobs.completed",
|
Self::JobCompleted { .. } => "jobs.completed",
|
||||||
Self::JobFailed { .. } => "jobs.failed",
|
Self::JobFailed { .. } => "jobs.failed",
|
||||||
|
Self::UserCreated { .. } => "users.created",
|
||||||
|
Self::UserDeleted { .. } => "users.deleted",
|
||||||
|
Self::AlbumCreated { .. } => "albums.created",
|
||||||
|
Self::TagCreated { .. } => "tags.created",
|
||||||
|
Self::DuplicateDetected { .. } => "duplicates.detected",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +191,41 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
error: error.clone(),
|
error: error.clone(),
|
||||||
timestamp: timestamp.to_string(),
|
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(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,6 +336,44 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
error,
|
error,
|
||||||
timestamp: parse_timestamp(×tamp)?,
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
},
|
},
|
||||||
|
EventPayload::UserCreated { user_id, timestamp } => DomainEvent::UserCreated {
|
||||||
|
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::UserDeleted { user_id, timestamp } => DomainEvent::UserDeleted {
|
||||||
|
user_id: SystemId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
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(×tamp)?,
|
||||||
|
},
|
||||||
|
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(×tamp)?,
|
||||||
|
},
|
||||||
|
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(×tamp)?,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
crates/adapters/postgres/migrations/013_asset_stacks.sql
Normal file
9
crates/adapters/postgres/migrations/013_asset_stacks.sql
Normal 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);
|
||||||
10
crates/adapters/postgres/migrations/014_refresh_tokens.sql
Normal file
10
crates/adapters/postgres/migrations/014_refresh_tokens.sql
Normal 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);
|
||||||
@@ -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);
|
||||||
1
crates/adapters/postgres/migrations/016_user_roles.sql
Normal file
1
crates/adapters/postgres/migrations/016_user_roles.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
UPDATE plugins SET name = 'scan_directory' WHERE plugin_id = 'a0000000-0000-4000-8000-000000000005';
|
||||||
3
crates/adapters/postgres/migrations/018_soft_delete.sql
Normal file
3
crates/adapters/postgres/migrations/018_soft_delete.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE assets ADD COLUMN deleted_at TIMESTAMPTZ NULL;
|
||||||
|
ALTER TABLE assets ADD COLUMN deleted_by UUID NULL REFERENCES users(id);
|
||||||
|
CREATE INDEX idx_assets_deleted ON assets (deleted_at) WHERE deleted_at IS NOT NULL;
|
||||||
@@ -3,12 +3,16 @@ use async_trait::async_trait;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use domain::{
|
use domain::{
|
||||||
entities::{
|
entities::{
|
||||||
Asset, AssetMetadata, AssetType, DerivativeAsset, DerivativeProfile, DetectionMethod,
|
Asset, AssetFilters, AssetMetadata, AssetStack, AssetStackMember, AssetType,
|
||||||
DuplicateCandidate, DuplicateGroup, DuplicateStatus, GenerationStatus, MetadataSource,
|
DerivativeAsset, DerivativeProfile, DetectionMethod, DuplicateCandidate, DuplicateGroup,
|
||||||
SourceReference,
|
DuplicateStatus, GenerationStatus, MetadataSource, SourceReference, StackMemberRole,
|
||||||
|
StackType,
|
||||||
},
|
},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AssetMetadataRepository, AssetRepository, DerivativeRepository, 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;
|
||||||
@@ -29,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 {
|
||||||
@@ -64,18 +70,98 @@ 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),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pg_repo!(PostgresAssetRepository);
|
pg_repo!(PostgresAssetRepository);
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_filter_params(filters: &AssetFilters) -> u32 {
|
||||||
|
let mut n = 0u32;
|
||||||
|
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]
|
||||||
impl AssetRepository for PostgresAssetRepository {
|
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())
|
||||||
@@ -89,7 +175,7 @@ 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())
|
||||||
@@ -107,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())
|
||||||
@@ -123,6 +215,121 @@ impl AssetRepository for PostgresAssetRepository {
|
|||||||
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,
|
||||||
@@ -162,6 +369,84 @@ impl AssetRepository for PostgresAssetRepository {
|
|||||||
.map_pg()?;
|
.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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -263,6 +548,23 @@ impl AssetMetadataRepository for PostgresAssetMetadataRepository {
|
|||||||
Ok(rows.into_iter().map(Into::into).collect())
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_by_asset_and_source(
|
async fn find_by_asset_and_source(
|
||||||
&self,
|
&self,
|
||||||
asset_id: &SystemId,
|
asset_id: &SystemId,
|
||||||
@@ -409,11 +711,18 @@ impl DuplicateRepository for PostgresDuplicateRepository {
|
|||||||
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_pg()?;
|
.map_pg()?;
|
||||||
@@ -597,3 +906,169 @@ impl DerivativeRepository for PostgresDerivativeRepository {
|
|||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ fn aggregate_id(event: &DomainEvent) -> Uuid {
|
|||||||
DomainEvent::JobEnqueued { job_id, .. }
|
DomainEvent::JobEnqueued { job_id, .. }
|
||||||
| DomainEvent::JobCompleted { job_id, .. }
|
| DomainEvent::JobCompleted { job_id, .. }
|
||||||
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
|
| 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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
/// Extension trait for converting `sqlx::Error` into `DomainError`.
|
|
||||||
pub trait MapDomainError<T> {
|
pub trait MapDomainError<T> {
|
||||||
fn map_pg(self) -> Result<T, DomainError>;
|
fn map_pg(self) -> Result<T, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
|
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
|
||||||
fn map_pg(self) -> Result<T, DomainError> {
|
fn map_pg(self) -> Result<T, DomainError> {
|
||||||
self.map_err(|e| DomainError::Internal(e.to_string()))
|
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()),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generates a Postgres repository struct with a `PgPool` field and a `new` constructor.
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// pg_repo!(PostgresFooRepository);
|
|
||||||
/// // expands to:
|
|
||||||
/// // pub struct PostgresFooRepository { pool: PgPool }
|
|
||||||
/// // impl PostgresFooRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } }
|
|
||||||
/// ```
|
|
||||||
macro_rules! pg_repo {
|
macro_rules! pg_repo {
|
||||||
($name:ident) => {
|
($name:ident) => {
|
||||||
pub struct $name {
|
pub struct $name {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ 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,6 +27,7 @@ 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -39,7 +42,7 @@ 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)
|
||||||
@@ -54,7 +57,7 @@ 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)
|
||||||
@@ -69,7 +72,7 @@ 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)
|
||||||
@@ -81,18 +84,20 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
|
|
||||||
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
|
||||||
@@ -108,4 +113,91 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.map_pg()?;
|
.map_pg()?;
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,59 @@ impl JobRepository for PostgresJobRepository {
|
|||||||
Ok(row.map(Into::into))
|
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,
|
||||||
@@ -352,6 +405,17 @@ impl PluginRepository for PostgresPluginRepository {
|
|||||||
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
|
||||||
@@ -468,6 +532,17 @@ impl PipelineRepository for PostgresPipelineRepository {
|
|||||||
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
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ impl LibraryPathRepository for PostgresLibraryPathRepository {
|
|||||||
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
|
||||||
@@ -180,7 +192,7 @@ 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)
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
91
crates/adapters/storage/src/volume_resolver.rs
Normal file
91
crates/adapters/storage/src/volume_resolver.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
|
|||||||
81
crates/application/src/catalog/commands/create_stack.rs
Normal file
81
crates/application/src/catalog/commands/create_stack.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
128
crates/application/src/catalog/commands/delete_asset.rs
Normal file
128
crates/application/src/catalog/commands/delete_asset.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
91
crates/application/src/catalog/commands/resolve_duplicate.rs
Normal file
91
crates/application/src/catalog/commands/resolve_duplicate.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
35
crates/application/src/catalog/commands/restore_asset.rs
Normal file
35
crates/application/src/catalog/commands/restore_asset.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use domain::{errors::DomainError, ports::AssetRepository, value_objects::SystemId};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct RestoreAssetCommand {
|
||||||
|
pub asset_id: SystemId,
|
||||||
|
pub user_id: SystemId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RestoreAssetHandler {
|
||||||
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RestoreAssetHandler {
|
||||||
|
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
|
||||||
|
Self { asset_repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, cmd: RestoreAssetCommand) -> Result<(), DomainError> {
|
||||||
|
let asset = self
|
||||||
|
.asset_repo
|
||||||
|
.find_by_id(&cmd.asset_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
|
||||||
|
|
||||||
|
if asset.owner_user_id != cmd.user_id {
|
||||||
|
return Err(DomainError::Forbidden("Access denied".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !asset.is_deleted() {
|
||||||
|
return Err(DomainError::Validation("Asset is not trashed".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.asset_repo.restore(&cmd.asset_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,26 @@ pub mod commands;
|
|||||||
pub mod queries;
|
pub mod queries;
|
||||||
pub mod visibility;
|
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::{
|
pub use queries::read_derivative::{
|
||||||
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
|
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
|
||||||
};
|
};
|
||||||
|
pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery, SearchResult};
|
||||||
pub use visibility::VisibilityFilteredAssetRepository;
|
pub use visibility::VisibilityFilteredAssetRepository;
|
||||||
|
|||||||
32
crates/application/src/catalog/queries/get_date_summary.rs
Normal file
32
crates/application/src/catalog/queries/get_date_summary.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/application/src/catalog/queries/get_stack.rs
Normal file
31
crates/application/src/catalog/queries/get_stack.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ pub struct GetTimelineQuery {
|
|||||||
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>,
|
||||||
@@ -51,24 +56,31 @@ impl GetTimelineHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(&self, query: GetTimelineQuery) -> Result<TimelineResult, DomainError> {
|
||||||
&self,
|
|
||||||
query: GetTimelineQuery,
|
|
||||||
) -> Result<Vec<(Asset, StructuredData)>, DomainError> {
|
|
||||||
let caller_id = query.caller_id.unwrap_or(query.owner_id);
|
let caller_id = query.caller_id.unwrap_or(query.owner_id);
|
||||||
let repo = self.effective_repo(caller_id);
|
let repo = self.effective_repo(caller_id);
|
||||||
|
|
||||||
|
let total = repo.count_by_owner(&query.owner_id).await?;
|
||||||
let assets = repo
|
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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
crates/application/src/catalog/queries/list_stacks.rs
Normal file
22
crates/application/src/catalog/queries/list_stacks.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/application/src/catalog/queries/list_trash.rs
Normal file
34
crates/application/src/catalog/queries/list_trash.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use domain::{
|
||||||
|
entities::Asset, errors::DomainError, ports::AssetRepository, value_objects::SystemId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ListTrashQuery {
|
||||||
|
pub owner_id: SystemId,
|
||||||
|
pub limit: u32,
|
||||||
|
pub offset: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TrashResult {
|
||||||
|
pub assets: Vec<Asset>,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListTrashHandler {
|
||||||
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListTrashHandler {
|
||||||
|
pub fn new(asset_repo: Arc<dyn AssetRepository>) -> Self {
|
||||||
|
Self { asset_repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, query: ListTrashQuery) -> Result<TrashResult, DomainError> {
|
||||||
|
let total = self.asset_repo.count_trashed(&query.owner_id).await?;
|
||||||
|
let assets = self
|
||||||
|
.asset_repo
|
||||||
|
.find_trashed_by_owner(&query.owner_id, query.limit, query.offset)
|
||||||
|
.await?;
|
||||||
|
Ok(TrashResult { assets, total })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +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 read_derivative;
|
||||||
|
pub mod search_assets;
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use bytes::Bytes;
|
|
||||||
use domain::{
|
use domain::{
|
||||||
entities::{DerivativeProfile, GenerationStatus},
|
entities::{DerivativeProfile, GenerationStatus},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{DerivativeRepository, FileStoragePort},
|
ports::{DataStream, DerivativeRepository, FileStoragePort},
|
||||||
value_objects::SystemId,
|
value_objects::SystemId,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -10,25 +9,30 @@ use std::sync::Arc;
|
|||||||
pub struct ReadDerivativeQuery {
|
pub struct ReadDerivativeQuery {
|
||||||
pub asset_id: SystemId,
|
pub asset_id: SystemId,
|
||||||
pub profile: DerivativeProfile,
|
pub profile: DerivativeProfile,
|
||||||
|
pub caller_id: SystemId,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct DerivativeFileResult {
|
pub struct DerivativeFileResult {
|
||||||
pub data: Bytes,
|
pub stream: DataStream,
|
||||||
|
pub size: u64,
|
||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ReadDerivativeHandler {
|
pub struct ReadDerivativeHandler {
|
||||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
|
asset_repo: Arc<dyn domain::ports::AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReadDerivativeHandler {
|
impl ReadDerivativeHandler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
derivative_repo: Arc<dyn DerivativeRepository>,
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
|
asset_repo: Arc<dyn domain::ports::AssetRepository>,
|
||||||
file_storage: Arc<dyn FileStoragePort>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
derivative_repo,
|
derivative_repo,
|
||||||
|
asset_repo,
|
||||||
file_storage,
|
file_storage,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +41,15 @@ impl ReadDerivativeHandler {
|
|||||||
&self,
|
&self,
|
||||||
query: ReadDerivativeQuery,
|
query: ReadDerivativeQuery,
|
||||||
) -> Result<DerivativeFileResult, DomainError> {
|
) -> 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
|
let derivative = self
|
||||||
.derivative_repo
|
.derivative_repo
|
||||||
.find_by_asset_and_profile(&query.asset_id, query.profile)
|
.find_by_asset_and_profile(&query.asset_id, query.profile)
|
||||||
@@ -55,13 +68,14 @@ impl ReadDerivativeHandler {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = self
|
let (stream, size) = self
|
||||||
.file_storage
|
.file_storage
|
||||||
.read_file(&derivative.storage_path)
|
.open_file(&derivative.storage_path)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(DerivativeFileResult {
|
Ok(DerivativeFileResult {
|
||||||
data,
|
stream,
|
||||||
|
size,
|
||||||
mime_type: derivative.mime_type,
|
mime_type: derivative.mime_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
42
crates/application/src/catalog/queries/search_assets.rs
Normal file
42
crates/application/src/catalog/queries/search_assets.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,19 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
catalog::entities::Asset,
|
catalog::entities::{Asset, AssetFilters},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AssetRepository, ShareRepository},
|
ports::{AssetRepository, ShareRepository},
|
||||||
|
sharing::entities::ShareTarget,
|
||||||
value_objects::{Checksum, SystemId},
|
value_objects::{Checksum, SystemId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
/// Decorator that wraps an `AssetRepository` and filters query results
|
|
||||||
/// based on sharing permissions. The caller sees only assets they own
|
|
||||||
/// or have been granted access to via a `ShareScope` + `ShareTarget`.
|
|
||||||
///
|
|
||||||
/// Write operations (`save`, `delete`) pass through to the inner repository
|
|
||||||
/// unchanged — authorization for writes is handled at the use-case layer.
|
|
||||||
pub struct VisibilityFilteredAssetRepository {
|
pub struct VisibilityFilteredAssetRepository {
|
||||||
inner: Arc<dyn AssetRepository>,
|
inner: Arc<dyn AssetRepository>,
|
||||||
share_repo: Arc<dyn ShareRepository>,
|
share_repo: Arc<dyn ShareRepository>,
|
||||||
caller_id: SystemId,
|
caller_id: SystemId,
|
||||||
|
caller_targets: OnceCell<Vec<ShareTarget>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VisibilityFilteredAssetRepository {
|
impl VisibilityFilteredAssetRepository {
|
||||||
@@ -29,17 +26,24 @@ impl VisibilityFilteredAssetRepository {
|
|||||||
inner,
|
inner,
|
||||||
share_repo,
|
share_repo,
|
||||||
caller_id,
|
caller_id,
|
||||||
|
caller_targets: OnceCell::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` if the caller owns the asset or has been granted
|
async fn get_caller_targets(&self) -> Result<&[ShareTarget], DomainError> {
|
||||||
/// access through a share scope that targets them.
|
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> {
|
async fn caller_can_access(&self, asset: &Asset) -> Result<bool, DomainError> {
|
||||||
if asset.owner_user_id == self.caller_id {
|
if asset.owner_user_id == self.caller_id {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all share scopes that cover this asset
|
|
||||||
let scopes = self
|
let scopes = self
|
||||||
.share_repo
|
.share_repo
|
||||||
.find_scopes_for_resource(&asset.asset_id)
|
.find_scopes_for_resource(&asset.asset_id)
|
||||||
@@ -49,14 +53,8 @@ impl VisibilityFilteredAssetRepository {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all share targets that name this caller
|
let caller_targets = self.get_caller_targets().await?;
|
||||||
let caller_targets = self
|
|
||||||
.share_repo
|
|
||||||
.find_targets_for_user(&self.caller_id)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// The caller has access if any of their targets reference a scope
|
|
||||||
// that covers this asset.
|
|
||||||
for scope in &scopes {
|
for scope in &scopes {
|
||||||
if scope.is_expired() {
|
if scope.is_expired() {
|
||||||
continue;
|
continue;
|
||||||
@@ -68,6 +66,16 @@ impl VisibilityFilteredAssetRepository {
|
|||||||
|
|
||||||
Ok(false)
|
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]
|
#[async_trait]
|
||||||
@@ -82,13 +90,7 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
|
|||||||
|
|
||||||
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 assets = self.inner.find_by_checksum(checksum).await?;
|
let assets = self.inner.find_by_checksum(checksum).await?;
|
||||||
let mut visible = Vec::with_capacity(assets.len());
|
self.filter_visible(assets).await
|
||||||
for asset in assets {
|
|
||||||
if self.caller_can_access(&asset).await? {
|
|
||||||
visible.push(asset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(visible)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_by_owner(
|
async fn find_by_owner(
|
||||||
@@ -98,18 +100,45 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
|
|||||||
offset: u32,
|
offset: u32,
|
||||||
) -> Result<Vec<Asset>, DomainError> {
|
) -> Result<Vec<Asset>, DomainError> {
|
||||||
if owner_id == &self.caller_id {
|
if owner_id == &self.caller_id {
|
||||||
// Querying own assets — no filtering needed.
|
|
||||||
return self.inner.find_by_owner(owner_id, limit, offset).await;
|
return self.inner.find_by_owner(owner_id, limit, offset).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let assets = self.inner.find_by_owner(owner_id, limit, offset).await?;
|
let assets = self.inner.find_by_owner(owner_id, limit, offset).await?;
|
||||||
let mut visible = Vec::with_capacity(assets.len());
|
self.filter_visible(assets).await
|
||||||
for asset in assets {
|
|
||||||
if self.caller_can_access(&asset).await? {
|
|
||||||
visible.push(asset);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
Ok(visible)
|
|
||||||
|
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> {
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
@@ -119,6 +148,26 @@ impl AssetRepository for VisibilityFilteredAssetRepository {
|
|||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
self.inner.delete(id).await
|
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)]
|
#[cfg(test)]
|
||||||
@@ -198,7 +247,6 @@ mod tests {
|
|||||||
|
|
||||||
let share_repo = Arc::new(InMemoryShareRepository::new());
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
// Create a share scope on the asset and target the friend
|
|
||||||
let scope = share_asset(asset.asset_id, owner_id);
|
let scope = share_asset(asset.asset_id, owner_id);
|
||||||
share_repo.save_scope(&scope).await.unwrap();
|
share_repo.save_scope(&scope).await.unwrap();
|
||||||
|
|
||||||
@@ -220,7 +268,6 @@ mod tests {
|
|||||||
|
|
||||||
let asset_a = make_asset(owner_id);
|
let asset_a = make_asset(owner_id);
|
||||||
let mut asset_b = make_asset(stranger_id);
|
let mut asset_b = make_asset(stranger_id);
|
||||||
// Give asset_b the same checksum as asset_a
|
|
||||||
asset_b.source_reference.checksum = asset_a.source_reference.checksum.clone();
|
asset_b.source_reference.checksum = asset_a.source_reference.checksum.clone();
|
||||||
|
|
||||||
let inner = Arc::new(InMemoryAssetRepository::new());
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
@@ -229,7 +276,6 @@ mod tests {
|
|||||||
|
|
||||||
let share_repo = Arc::new(InMemoryShareRepository::new());
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
// Stranger queries by checksum — should only see their own
|
|
||||||
let filtered =
|
let filtered =
|
||||||
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
||||||
|
|
||||||
@@ -268,7 +314,6 @@ mod tests {
|
|||||||
|
|
||||||
let share_repo = Arc::new(InMemoryShareRepository::new());
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
// Stranger queries owner's assets without a share — should get nothing
|
|
||||||
let filtered =
|
let filtered =
|
||||||
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
16
crates/application/src/identity/commands/logout.rs
Normal file
16
crates/application/src/identity/commands/logout.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
61
crates/application/src/identity/commands/refresh_token.rs
Normal file
61
crates/application/src/identity/commands/refresh_token.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
31
crates/application/src/organization/commands/delete_album.rs
Normal file
31
crates/application/src/organization/commands/delete_album.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use domain::{errors::DomainError, ports::AlbumRepository, value_objects::SystemId};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct DeleteAlbumCommand {
|
||||||
|
pub album_id: SystemId,
|
||||||
|
pub user_id: SystemId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeleteAlbumHandler {
|
||||||
|
repo: Arc<dyn AlbumRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteAlbumHandler {
|
||||||
|
pub fn new(repo: Arc<dyn AlbumRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, cmd: DeleteAlbumCommand) -> Result<(), DomainError> {
|
||||||
|
let album = self
|
||||||
|
.repo
|
||||||
|
.find_by_id(&cmd.album_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound(format!("Album {} not found", cmd.album_id)))?;
|
||||||
|
|
||||||
|
if album.creator_user_id != cmd.user_id {
|
||||||
|
return Err(DomainError::Forbidden("Access denied".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.repo.delete(&cmd.album_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,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};
|
||||||
|
|||||||
44
crates/application/src/organization/commands/update_album.rs
Normal file
44
crates/application/src/organization/commands/update_album.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
|
|||||||
22
crates/application/src/organization/queries/list_albums.rs
Normal file
22
crates/application/src/organization/queries/list_albums.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
pub mod get_album;
|
pub mod get_album;
|
||||||
|
pub mod list_albums;
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ 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::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,
|
||||||
};
|
};
|
||||||
|
|||||||
34
crates/application/src/processing/queries/list_jobs.rs
Normal file
34
crates/application/src/processing/queries/list_jobs.rs
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
18
crates/application/src/processing/queries/list_pipelines.rs
Normal file
18
crates/application/src/processing/queries/list_pipelines.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/application/src/processing/queries/list_plugins.rs
Normal file
16
crates/application/src/processing/queries/list_plugins.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,4 @@
|
|||||||
|
pub mod list_jobs;
|
||||||
|
pub mod list_pipelines;
|
||||||
|
pub mod list_plugins;
|
||||||
pub mod report_batch_progress;
|
pub mod report_batch_progress;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const BATCH_SIZE: u32 = 500;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct FullExportCommand {
|
pub struct FullExportCommand {
|
||||||
pub owner_id: SystemId,
|
pub owner_id: SystemId,
|
||||||
@@ -36,21 +38,36 @@ impl FullExportHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(&self, cmd: FullExportCommand) -> Result<u32, DomainError> {
|
pub async fn execute(&self, cmd: FullExportCommand) -> Result<u32, DomainError> {
|
||||||
|
let mut count = 0u32;
|
||||||
|
let mut offset = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
let assets = self
|
let assets = self
|
||||||
.asset_repo
|
.asset_repo
|
||||||
.find_by_owner(&cmd.owner_id, u32::MAX, 0)
|
.find_by_owner(&cmd.owner_id, BATCH_SIZE, offset)
|
||||||
.await?;
|
.await?;
|
||||||
let mut count = 0u32;
|
|
||||||
|
if assets.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let asset_ids: Vec<SystemId> = assets.iter().map(|a| a.asset_id).collect();
|
||||||
|
let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?;
|
||||||
|
|
||||||
for asset in &assets {
|
for asset in &assets {
|
||||||
let layers = self.metadata_repo.find_by_asset(&asset.asset_id).await?;
|
let layers: Vec<_> = all_layers
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.asset_id == asset.asset_id)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
let resolved = resolve_metadata(&layers);
|
let resolved = resolve_metadata(&layers);
|
||||||
|
|
||||||
let mut record = match self.sidecar_repo.find_by_asset(&asset.asset_id).await? {
|
let mut record = match self.sidecar_repo.find_by_asset(&asset.asset_id).await? {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => {
|
None => SidecarRecord::new(
|
||||||
SidecarRecord::new(asset.asset_id, format!("sidecars/{}.xmp", asset.asset_id))
|
asset.asset_id,
|
||||||
}
|
format!("sidecars/{}.xmp", asset.asset_id),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
self.writer
|
self.writer
|
||||||
@@ -62,6 +79,12 @@ impl FullExportHandler {
|
|||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset += assets.len() as u32;
|
||||||
|
if (assets.len() as u32) < BATCH_SIZE {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const BATCH_SIZE: u32 = 500;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct FullImportCommand {
|
pub struct FullImportCommand {
|
||||||
pub owner_id: SystemId,
|
pub owner_id: SystemId,
|
||||||
@@ -36,38 +38,43 @@ impl FullImportHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(&self, cmd: FullImportCommand) -> Result<u32, DomainError> {
|
pub async fn execute(&self, cmd: FullImportCommand) -> Result<u32, DomainError> {
|
||||||
|
let mut count = 0u32;
|
||||||
|
let mut offset = 0u32;
|
||||||
|
|
||||||
|
loop {
|
||||||
let assets = self
|
let assets = self
|
||||||
.asset_repo
|
.asset_repo
|
||||||
.find_by_owner(&cmd.owner_id, u32::MAX, 0)
|
.find_by_owner(&cmd.owner_id, BATCH_SIZE, offset)
|
||||||
.await?;
|
.await?;
|
||||||
let mut count = 0u32;
|
|
||||||
|
if assets.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
for asset in &assets {
|
for asset in &assets {
|
||||||
let record = match self.sidecar_repo.find_by_asset(&asset.asset_id).await? {
|
let record = match self.sidecar_repo.find_by_asset(&asset.asset_id).await? {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
None => {
|
None => SidecarRecord::new(
|
||||||
// No sidecar record — try creating one to read from
|
asset.asset_id,
|
||||||
SidecarRecord::new(asset.asset_id, format!("sidecars/{}.xmp", asset.asset_id))
|
format!("sidecars/{}.xmp", asset.asset_id),
|
||||||
}
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
match self.writer.read_sidecar(&record.sidecar_storage_path).await {
|
if let Ok(data) = self.writer.read_sidecar(&record.sidecar_storage_path).await {
|
||||||
Ok(data) => {
|
|
||||||
let metadata = AssetMetadata::new(
|
|
||||||
asset.asset_id,
|
|
||||||
MetadataSource::ExifExtracted,
|
|
||||||
data.clone(),
|
|
||||||
);
|
|
||||||
self.metadata_repo.save(&metadata).await?;
|
|
||||||
let hash = hash_structured_data(&data);
|
let hash = hash_structured_data(&data);
|
||||||
|
let metadata =
|
||||||
|
AssetMetadata::new(asset.asset_id, MetadataSource::ExifExtracted, data);
|
||||||
|
self.metadata_repo.save(&metadata).await?;
|
||||||
let mut record = record;
|
let mut record = record;
|
||||||
record.mark_synced(hash);
|
record.mark_synced(hash);
|
||||||
self.sidecar_repo.save(&record).await?;
|
self.sidecar_repo.save(&record).await?;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
Err(_) => {
|
|
||||||
// Sidecar file missing — skip
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
offset += assets.len() as u32;
|
||||||
|
if (assets.len() as u32) < BATCH_SIZE {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use domain::{errors::DomainError, ports::LibraryPathRepository, value_objects::SystemId};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct DeleteLibraryPathHandler {
|
||||||
|
repo: Arc<dyn LibraryPathRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteLibraryPathHandler {
|
||||||
|
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, id: SystemId) -> Result<(), DomainError> {
|
||||||
|
self.repo.delete(&id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/application/src/storage/commands/delete_volume.rs
Normal file
16
crates/application/src/storage/commands/delete_volume.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use domain::{errors::DomainError, ports::StorageVolumeRepository, value_objects::SystemId};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct DeleteVolumeHandler {
|
||||||
|
repo: Arc<dyn StorageVolumeRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteVolumeHandler {
|
||||||
|
pub fn new(repo: Arc<dyn StorageVolumeRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, id: SystemId) -> Result<(), DomainError> {
|
||||||
|
self.repo.delete(&id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
pub mod delete_library_path;
|
||||||
|
pub mod delete_volume;
|
||||||
pub mod ingest_asset;
|
pub mod ingest_asset;
|
||||||
pub mod register_library_path;
|
pub mod register_library_path;
|
||||||
pub mod register_volume;
|
pub mod register_volume;
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|
||||||
|
pub use commands::delete_library_path::DeleteLibraryPathHandler;
|
||||||
|
pub use commands::delete_volume::DeleteVolumeHandler;
|
||||||
pub use commands::ingest_asset::{IngestAssetCommand, IngestAssetHandler};
|
pub use commands::ingest_asset::{IngestAssetCommand, IngestAssetHandler};
|
||||||
pub use commands::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
|
pub use commands::register_library_path::{RegisterLibraryPathCommand, RegisterLibraryPathHandler};
|
||||||
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
|
pub use commands::register_volume::{RegisterVolumeCommand, RegisterVolumeHandler};
|
||||||
pub use queries::check_quota::{CheckQuotaHandler, CheckQuotaQuery};
|
pub use queries::check_quota::{CheckQuotaHandler, CheckQuotaQuery};
|
||||||
|
pub use queries::list_all_library_paths::ListAllLibraryPathsHandler;
|
||||||
|
pub use queries::list_ingest_paths::{ListIngestPathsHandler, ListIngestPathsQuery};
|
||||||
|
pub use queries::list_volumes::ListVolumesHandler;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use domain::{entities::LibraryPath, errors::DomainError, ports::LibraryPathRepository};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ListAllLibraryPathsHandler {
|
||||||
|
repo: Arc<dyn LibraryPathRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListAllLibraryPathsHandler {
|
||||||
|
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self) -> Result<Vec<LibraryPath>, DomainError> {
|
||||||
|
self.repo.find_all().await
|
||||||
|
}
|
||||||
|
}
|
||||||
28
crates/application/src/storage/queries/list_ingest_paths.rs
Normal file
28
crates/application/src/storage/queries/list_ingest_paths.rs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
use domain::{
|
||||||
|
entities::LibraryPath,
|
||||||
|
errors::DomainError,
|
||||||
|
ports::LibraryPathRepository,
|
||||||
|
value_objects::SystemId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ListIngestPathsQuery {
|
||||||
|
pub user_id: SystemId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListIngestPathsHandler {
|
||||||
|
repo: Arc<dyn LibraryPathRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListIngestPathsHandler {
|
||||||
|
pub fn new(repo: Arc<dyn LibraryPathRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
query: ListIngestPathsQuery,
|
||||||
|
) -> Result<Vec<LibraryPath>, DomainError> {
|
||||||
|
self.repo.find_ingest_destinations(&query.user_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
16
crates/application/src/storage/queries/list_volumes.rs
Normal file
16
crates/application/src/storage/queries/list_volumes.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use domain::{entities::StorageVolume, errors::DomainError, ports::StorageVolumeRepository};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ListVolumesHandler {
|
||||||
|
repo: Arc<dyn StorageVolumeRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListVolumesHandler {
|
||||||
|
pub fn new(repo: Arc<dyn StorageVolumeRepository>) -> Self {
|
||||||
|
Self { repo }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self) -> Result<Vec<StorageVolume>, DomainError> {
|
||||||
|
self.repo.find_all().await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,4 @@
|
|||||||
pub mod check_quota;
|
pub mod check_quota;
|
||||||
|
pub mod list_all_library_paths;
|
||||||
|
pub mod list_ingest_paths;
|
||||||
|
pub mod list_volumes;
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ impl FileStoragePort for InMemoryFileStorage {
|
|||||||
.ok_or_else(|| DomainError::NotFound(format!("File not found: {path}")))
|
.ok_or_else(|| DomainError::NotFound(format!("File not found: {path}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn open_file(&self, path: &str) -> Result<(domain::ports::DataStream, u64), DomainError> {
|
||||||
|
let data = self.read_file(path).await?;
|
||||||
|
let len = data.len() as u64;
|
||||||
|
let stream = futures::stream::once(async move { Ok(data) });
|
||||||
|
Ok((Box::pin(stream), len))
|
||||||
|
}
|
||||||
|
|
||||||
async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
|
async fn delete_file(&self, path: &str) -> Result<(), DomainError> {
|
||||||
self.files.lock().await.remove(path);
|
self.files.lock().await.remove(path);
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
entities::{
|
entities::{
|
||||||
Album, Asset, AssetMetadata, AssetTag, DuplicateGroup, DuplicateStatus, Group,
|
Album, Asset, AssetFilters, AssetMetadata, AssetTag, DuplicateGroup, DuplicateStatus,
|
||||||
IngestSession, InviteCode, Job, JobBatch, JobStatus, LibraryPath, MetadataSource, Plugin,
|
Group, IngestSession, InviteCode, Job, JobBatch, JobStatus, LibraryPath, MetadataSource,
|
||||||
ProcessingPipeline, QuotaDefinition, Role, ShareLink, ShareScope, ShareTarget,
|
Plugin, ProcessingPipeline, QuotaDefinition, RefreshToken, Role, ShareLink, ShareScope,
|
||||||
SidecarRecord, StorageVolume, SyncStatus, Tag, UsageLedgerEntry, UsageType, User,
|
ShareTarget, SidecarRecord, StorageVolume, SyncStatus, Tag, UsageLedgerEntry, UsageType,
|
||||||
|
User,
|
||||||
},
|
},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{
|
ports::{
|
||||||
AlbumRepository, AssetMetadataRepository, AssetRepository, DuplicateRepository,
|
AlbumRepository, AssetMetadataRepository, AssetRepository, DuplicateRepository,
|
||||||
GroupRepository, IngestSessionRepository, IngestTransaction, JobBatchRepository,
|
GroupRepository, IngestSessionRepository, IngestTransaction, JobBatchRepository,
|
||||||
JobRepository, LibraryPathRepository, PipelineRepository, PluginRepository,
|
JobRepository, LibraryPathRepository, PipelineRepository, PluginRepository,
|
||||||
QuotaRepository, RoleRepository, ShareRepository, SidecarRepository,
|
QuotaRepository, RefreshTokenRepository, RoleRepository, ShareRepository,
|
||||||
StorageVolumeRepository, TagRepository, UsageLedgerRepository, UserRepository,
|
SidecarRepository, StorageVolumeRepository, TagRepository, UsageLedgerRepository,
|
||||||
|
UserRepository,
|
||||||
},
|
},
|
||||||
value_objects::{Checksum, DateTimeStamp, Email, SystemId},
|
value_objects::{Checksum, DateTimeStamp, Email, SystemId},
|
||||||
};
|
};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
macro_rules! in_memory_repo {
|
||||||
|
($name:ident, $entity:ty) => {
|
||||||
|
pub struct $name {
|
||||||
|
data: Mutex<HashMap<String, $entity>>,
|
||||||
|
}
|
||||||
|
impl $name {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
data: Mutex::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for $name {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// --- InMemoryUserRepository ---
|
// --- InMemoryUserRepository ---
|
||||||
|
|
||||||
pub struct InMemoryUserRepository {
|
pub struct InMemoryUserRepository {
|
||||||
@@ -81,27 +103,13 @@ impl UserRepository for InMemoryUserRepository {
|
|||||||
self.users.lock().await.remove(&id.to_string());
|
self.users.lock().await.remove(&id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- InMemoryAssetRepository ---
|
async fn count(&self) -> Result<u64, DomainError> {
|
||||||
|
Ok(self.users.lock().await.len() as u64)
|
||||||
pub struct InMemoryAssetRepository {
|
|
||||||
data: Mutex<HashMap<String, Asset>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryAssetRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InMemoryAssetRepository {
|
in_memory_repo!(InMemoryAssetRepository, Asset);
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AssetRepository for InMemoryAssetRepository {
|
impl AssetRepository for InMemoryAssetRepository {
|
||||||
@@ -141,6 +149,49 @@ impl AssetRepository for InMemoryAssetRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.filter(|a| &a.owner_user_id == owner_id)
|
||||||
|
.count() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
_filters: &AssetFilters,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
self.find_by_owner(owner_id, limit, offset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
_filters: &AssetFilters,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
self.count_by_owner(owner_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn date_summary(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
|
||||||
|
let data = self.data.lock().await;
|
||||||
|
let mut map = std::collections::BTreeMap::<chrono::NaiveDate, u64>::new();
|
||||||
|
for asset in data.values() {
|
||||||
|
if &asset.owner_user_id == owner_id {
|
||||||
|
let date = asset.created_at.as_datetime().date_naive();
|
||||||
|
*map.entry(date).or_default() += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(map.into_iter().rev().collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
self.data
|
self.data
|
||||||
.lock()
|
.lock()
|
||||||
@@ -153,27 +204,42 @@ impl AssetRepository for InMemoryAssetRepository {
|
|||||||
self.data.lock().await.remove(&id.to_string());
|
self.data.lock().await.remove(&id.to_string());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// --- InMemoryAlbumRepository ---
|
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
|
||||||
|
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
|
||||||
pub struct InMemoryAlbumRepository {
|
asset.trash(*deleted_by);
|
||||||
data: Mutex<HashMap<String, Album>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryAlbumRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
if let Some(asset) = self.data.lock().await.get_mut(&id.to_string()) {
|
||||||
|
asset.restore();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values()
|
||||||
|
.filter(|a| a.deleted_at.as_ref().map_or(false, |d| *d.as_datetime() < cutoff))
|
||||||
|
.cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values()
|
||||||
|
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
|
||||||
|
.count() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values()
|
||||||
|
.filter(|a| &a.owner_user_id == owner_id && a.is_deleted())
|
||||||
|
.skip(offset as usize).take(limit as usize)
|
||||||
|
.cloned().collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InMemoryAlbumRepository {
|
in_memory_repo!(InMemoryAlbumRepository, Album);
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl AlbumRepository for InMemoryAlbumRepository {
|
impl AlbumRepository for InMemoryAlbumRepository {
|
||||||
@@ -206,25 +272,7 @@ impl AlbumRepository for InMemoryAlbumRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryJobRepository ---
|
in_memory_repo!(InMemoryJobRepository, Job);
|
||||||
|
|
||||||
pub struct InMemoryJobRepository {
|
|
||||||
data: Mutex<HashMap<String, Job>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryJobRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryJobRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl JobRepository for InMemoryJobRepository {
|
impl JobRepository for InMemoryJobRepository {
|
||||||
@@ -252,6 +300,24 @@ impl JobRepository for InMemoryJobRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_all(
|
||||||
|
&self,
|
||||||
|
_status: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Job>, DomainError> {
|
||||||
|
let all: Vec<Job> = self.data.lock().await.values().cloned().collect();
|
||||||
|
Ok(all
|
||||||
|
.into_iter()
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit as usize)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(&self, _status: Option<&str>) -> Result<u64, DomainError> {
|
||||||
|
Ok(self.data.lock().await.len() as u64)
|
||||||
|
}
|
||||||
|
|
||||||
async fn save(&self, job: &Job) -> Result<(), DomainError> {
|
async fn save(&self, job: &Job) -> Result<(), DomainError> {
|
||||||
self.data
|
self.data
|
||||||
.lock()
|
.lock()
|
||||||
@@ -261,25 +327,7 @@ impl JobRepository for InMemoryJobRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryRoleRepository ---
|
in_memory_repo!(InMemoryRoleRepository, Role);
|
||||||
|
|
||||||
pub struct InMemoryRoleRepository {
|
|
||||||
data: Mutex<HashMap<String, Role>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryRoleRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryRoleRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RoleRepository for InMemoryRoleRepository {
|
impl RoleRepository for InMemoryRoleRepository {
|
||||||
@@ -322,25 +370,7 @@ impl RoleRepository for InMemoryRoleRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryGroupRepository ---
|
in_memory_repo!(InMemoryGroupRepository, Group);
|
||||||
|
|
||||||
pub struct InMemoryGroupRepository {
|
|
||||||
data: Mutex<HashMap<String, Group>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryGroupRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryGroupRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GroupRepository for InMemoryGroupRepository {
|
impl GroupRepository for InMemoryGroupRepository {
|
||||||
@@ -373,25 +403,7 @@ impl GroupRepository for InMemoryGroupRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryStorageVolumeRepository ---
|
in_memory_repo!(InMemoryStorageVolumeRepository, StorageVolume);
|
||||||
|
|
||||||
pub struct InMemoryStorageVolumeRepository {
|
|
||||||
data: Mutex<HashMap<String, StorageVolume>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryStorageVolumeRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryStorageVolumeRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl StorageVolumeRepository for InMemoryStorageVolumeRepository {
|
impl StorageVolumeRepository for InMemoryStorageVolumeRepository {
|
||||||
@@ -417,25 +429,7 @@ impl StorageVolumeRepository for InMemoryStorageVolumeRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryLibraryPathRepository ---
|
in_memory_repo!(InMemoryLibraryPathRepository, LibraryPath);
|
||||||
|
|
||||||
pub struct InMemoryLibraryPathRepository {
|
|
||||||
data: Mutex<HashMap<String, LibraryPath>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryLibraryPathRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryLibraryPathRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl LibraryPathRepository for InMemoryLibraryPathRepository {
|
impl LibraryPathRepository for InMemoryLibraryPathRepository {
|
||||||
@@ -443,6 +437,10 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values().cloned().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> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -482,25 +480,7 @@ impl LibraryPathRepository for InMemoryLibraryPathRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryIngestSessionRepository ---
|
in_memory_repo!(InMemoryIngestSessionRepository, IngestSession);
|
||||||
|
|
||||||
pub struct InMemoryIngestSessionRepository {
|
|
||||||
data: Mutex<HashMap<String, IngestSession>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryIngestSessionRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryIngestSessionRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl IngestSessionRepository for InMemoryIngestSessionRepository {
|
impl IngestSessionRepository for InMemoryIngestSessionRepository {
|
||||||
@@ -528,25 +508,7 @@ impl IngestSessionRepository for InMemoryIngestSessionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryQuotaRepository ---
|
in_memory_repo!(InMemoryQuotaRepository, QuotaDefinition);
|
||||||
|
|
||||||
pub struct InMemoryQuotaRepository {
|
|
||||||
data: Mutex<HashMap<String, QuotaDefinition>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryQuotaRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryQuotaRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl QuotaRepository for InMemoryQuotaRepository {
|
impl QuotaRepository for InMemoryQuotaRepository {
|
||||||
@@ -662,6 +624,23 @@ impl AssetMetadataRepository for InMemoryAssetMetadataRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_by_assets(
|
||||||
|
&self,
|
||||||
|
asset_ids: &[SystemId],
|
||||||
|
) -> Result<Vec<AssetMetadata>, DomainError> {
|
||||||
|
let data = self.data.lock().await;
|
||||||
|
let mut results = Vec::new();
|
||||||
|
for id in asset_ids {
|
||||||
|
let prefix = format!("{id}:");
|
||||||
|
results.extend(
|
||||||
|
data.iter()
|
||||||
|
.filter(|(k, _)| k.starts_with(&prefix))
|
||||||
|
.map(|(_, v)| v.clone()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_by_asset_and_source(
|
async fn find_by_asset_and_source(
|
||||||
&self,
|
&self,
|
||||||
asset_id: &SystemId,
|
asset_id: &SystemId,
|
||||||
@@ -889,25 +868,7 @@ impl TagRepository for InMemoryTagRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryDuplicateRepository ---
|
in_memory_repo!(InMemoryDuplicateRepository, DuplicateGroup);
|
||||||
|
|
||||||
pub struct InMemoryDuplicateRepository {
|
|
||||||
data: Mutex<HashMap<String, DuplicateGroup>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryDuplicateRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryDuplicateRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DuplicateRepository for InMemoryDuplicateRepository {
|
impl DuplicateRepository for InMemoryDuplicateRepository {
|
||||||
@@ -915,13 +876,19 @@ impl DuplicateRepository for InMemoryDuplicateRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError> {
|
async fn find_unresolved(
|
||||||
|
&self,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<DuplicateGroup>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.values()
|
.values()
|
||||||
.filter(|g| g.status == DuplicateStatus::Unresolved)
|
.filter(|g| g.status == DuplicateStatus::Unresolved)
|
||||||
|
.skip(offset as usize)
|
||||||
|
.take(limit as usize)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
@@ -946,25 +913,7 @@ impl DuplicateRepository for InMemoryDuplicateRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemorySidecarRepository ---
|
in_memory_repo!(InMemorySidecarRepository, SidecarRecord);
|
||||||
|
|
||||||
pub struct InMemorySidecarRepository {
|
|
||||||
data: Mutex<HashMap<String, SidecarRecord>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemorySidecarRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemorySidecarRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SidecarRepository for InMemorySidecarRepository {
|
impl SidecarRepository for InMemorySidecarRepository {
|
||||||
@@ -1000,25 +949,7 @@ impl SidecarRepository for InMemorySidecarRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryJobBatchRepository ---
|
in_memory_repo!(InMemoryJobBatchRepository, JobBatch);
|
||||||
|
|
||||||
pub struct InMemoryJobBatchRepository {
|
|
||||||
data: Mutex<HashMap<String, JobBatch>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryJobBatchRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryJobBatchRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl JobBatchRepository for InMemoryJobBatchRepository {
|
impl JobBatchRepository for InMemoryJobBatchRepository {
|
||||||
@@ -1035,25 +966,7 @@ impl JobBatchRepository for InMemoryJobBatchRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryPluginRepository ---
|
in_memory_repo!(InMemoryPluginRepository, Plugin);
|
||||||
|
|
||||||
pub struct InMemoryPluginRepository {
|
|
||||||
data: Mutex<HashMap<String, Plugin>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryPluginRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryPluginRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PluginRepository for InMemoryPluginRepository {
|
impl PluginRepository for InMemoryPluginRepository {
|
||||||
@@ -1061,6 +974,10 @@ impl PluginRepository for InMemoryPluginRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values().cloned().collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -1081,25 +998,7 @@ impl PluginRepository for InMemoryPluginRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- InMemoryPipelineRepository ---
|
in_memory_repo!(InMemoryPipelineRepository, ProcessingPipeline);
|
||||||
|
|
||||||
pub struct InMemoryPipelineRepository {
|
|
||||||
data: Mutex<HashMap<String, ProcessingPipeline>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl InMemoryPipelineRepository {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
data: Mutex::new(HashMap::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for InMemoryPipelineRepository {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl PipelineRepository for InMemoryPipelineRepository {
|
impl PipelineRepository for InMemoryPipelineRepository {
|
||||||
@@ -1107,6 +1006,10 @@ impl PipelineRepository for InMemoryPipelineRepository {
|
|||||||
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
Ok(self.data.lock().await.get(&id.to_string()).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
||||||
|
Ok(self.data.lock().await.values().cloned().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> {
|
||||||
Ok(self
|
Ok(self
|
||||||
.data
|
.data
|
||||||
@@ -1216,3 +1119,36 @@ impl IngestTransaction for InMemoryIngestTransaction {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
in_memory_repo!(InMemoryRefreshTokenRepository, RefreshToken);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RefreshTokenRepository for InMemoryRefreshTokenRepository {
|
||||||
|
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
|
||||||
|
self.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(token.token_id.to_string(), token.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.data
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|t| t.token_hash == token_hash)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
self.data.lock().await.retain(|_, t| &t.user_id != user_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
self.data.lock().await.remove(&id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async fn returns_paginated_assets() {
|
|||||||
|
|
||||||
let handler = GetTimelineHandler::new(asset_repo, meta_repo);
|
let handler = GetTimelineHandler::new(asset_repo, meta_repo);
|
||||||
|
|
||||||
let page = handler
|
let result = handler
|
||||||
.execute(GetTimelineQuery {
|
.execute(GetTimelineQuery {
|
||||||
owner_id: owner,
|
owner_id: owner,
|
||||||
caller_id: None,
|
caller_id: None,
|
||||||
@@ -38,7 +38,8 @@ async fn returns_paginated_assets() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(page.len(), 3);
|
assert_eq!(result.items.len(), 3);
|
||||||
|
assert_eq!(result.total, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -48,7 +49,7 @@ async fn returns_empty_for_no_assets() {
|
|||||||
|
|
||||||
let handler = GetTimelineHandler::new(asset_repo, meta_repo);
|
let handler = GetTimelineHandler::new(asset_repo, meta_repo);
|
||||||
|
|
||||||
let page = handler
|
let result = handler
|
||||||
.execute(GetTimelineQuery {
|
.execute(GetTimelineQuery {
|
||||||
owner_id: SystemId::new(),
|
owner_id: SystemId::new(),
|
||||||
caller_id: None,
|
caller_id: None,
|
||||||
@@ -58,5 +59,6 @@ async fn returns_empty_for_no_assets() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(page.is_empty());
|
assert!(result.items.is_empty());
|
||||||
|
assert_eq!(result.total, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use domain::catalog::entities::{Asset, AssetType, SourceReference};
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use domain::ports::{AssetRepository, FileStoragePort};
|
use domain::ports::{AssetRepository, FileStoragePort};
|
||||||
use domain::value_objects::{Checksum, SystemId};
|
use domain::value_objects::{Checksum, SystemId};
|
||||||
|
use futures::StreamExt;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -17,7 +18,8 @@ async fn reads_file_successfully() {
|
|||||||
relative_path: "photos/inbox/cat.jpg".into(),
|
relative_path: "photos/inbox/cat.jpg".into(),
|
||||||
checksum: Checksum::new("a".repeat(64)).unwrap(),
|
checksum: Checksum::new("a".repeat(64)).unwrap(),
|
||||||
};
|
};
|
||||||
let asset = Asset::new(source, AssetType::Image, "image/jpeg", 512, SystemId::new());
|
let owner_id = SystemId::new();
|
||||||
|
let asset = Asset::new(source, AssetType::Image, "image/jpeg", 512, owner_id);
|
||||||
asset_repo.save(&asset).await.unwrap();
|
asset_repo.save(&asset).await.unwrap();
|
||||||
|
|
||||||
let file_data = Bytes::from(vec![0xFFu8; 512]);
|
let file_data = Bytes::from(vec![0xFFu8; 512]);
|
||||||
@@ -30,11 +32,14 @@ async fn reads_file_successfully() {
|
|||||||
let result = handler
|
let result = handler
|
||||||
.execute(ReadAssetFileQuery {
|
.execute(ReadAssetFileQuery {
|
||||||
asset_id: asset.asset_id,
|
asset_id: asset.asset_id,
|
||||||
|
caller_id: owner_id,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(result.data, file_data);
|
let chunks: Vec<Bytes> = result.stream.map(|r| r.unwrap()).collect().await;
|
||||||
|
let data: Bytes = chunks.into_iter().flatten().collect();
|
||||||
|
assert_eq!(data, file_data);
|
||||||
assert_eq!(result.mime_type, "image/jpeg");
|
assert_eq!(result.mime_type, "image/jpeg");
|
||||||
assert_eq!(result.filename, "cat.jpg");
|
assert_eq!(result.filename, "cat.jpg");
|
||||||
}
|
}
|
||||||
@@ -48,6 +53,7 @@ async fn rejects_nonexistent_asset() {
|
|||||||
let result = handler
|
let result = handler
|
||||||
.execute(ReadAssetFileQuery {
|
.execute(ReadAssetFileQuery {
|
||||||
asset_id: SystemId::new(),
|
asset_id: SystemId::new(),
|
||||||
|
caller_id: SystemId::new(),
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ pub struct Config {
|
|||||||
pub nats_url: String,
|
pub nats_url: String,
|
||||||
pub jwt_secret: String,
|
pub jwt_secret: String,
|
||||||
pub cors_allowed_origins: Vec<String>,
|
pub cors_allowed_origins: Vec<String>,
|
||||||
|
pub max_upload_bytes: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -26,6 +27,10 @@ impl Config {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.map(|s| s.trim().to_string())
|
.map(|s| s.trim().to_string())
|
||||||
.collect(),
|
.collect(),
|
||||||
|
max_upload_bytes: std::env::var("MAX_UPLOAD_BYTES")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(256 * 1024 * 1024),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
|
use axum::extract::DefaultBodyLimit;
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -32,7 +33,8 @@ pub async fn build_app(config: &Config) -> Result<Router> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
|
let storage_path = std::env::var("STORAGE_PATH").unwrap_or_else(|_| "./data/media".to_string());
|
||||||
let file_storage: Arc<LocalFileStorage> = Arc::new(LocalFileStorage::new(&storage_path));
|
let file_storage: Arc<dyn domain::ports::FileStoragePort> =
|
||||||
|
Arc::new(LocalFileStorage::new(&storage_path));
|
||||||
|
|
||||||
// Build per-context services
|
// Build per-context services
|
||||||
let identity = services::identity::build(&pool, &config.jwt_secret);
|
let identity = services::identity::build(&pool, &config.jwt_secret);
|
||||||
@@ -66,8 +68,9 @@ pub async fn build_app(config: &Config) -> Result<Router> {
|
|||||||
.allow_methods(Any)
|
.allow_methods(Any)
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
Ok(app_router()
|
Ok(app_router(&state)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
|
.layer(DefaultBodyLimit::max(config.max_upload_bytes))
|
||||||
.layer(TraceLayer::new_for_http())
|
.layer(TraceLayer::new_for_http())
|
||||||
.layer(cors))
|
.layer(cors))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use adapters_postgres::{
|
use adapters_postgres::{
|
||||||
PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresDerivativeRepository,
|
PgPool, PostgresAssetMetadataRepository, PostgresAssetRepository, PostgresAssetStackRepository,
|
||||||
PostgresDuplicateRepository, PostgresIngestTransaction,
|
PostgresDerivativeRepository, PostgresDuplicateRepository, PostgresIngestTransaction,
|
||||||
|
PostgresSidecarRepository,
|
||||||
};
|
};
|
||||||
use adapters_storage::LocalFileStorage;
|
use adapters_storage::LocalVolumeFileResolver;
|
||||||
|
use domain::ports::FileStoragePort;
|
||||||
use application::catalog::{
|
use application::catalog::{
|
||||||
GetAssetHandler, GetTimelineHandler, ReadAssetFileHandler, ReadDerivativeHandler,
|
CreateStackHandler, DeleteAssetHandler, DeleteStackHandler, DetectLivePhotosHandler,
|
||||||
RegisterAssetHandler, UpdateMetadataHandler,
|
GetAssetHandler, GetDateSummaryHandler, GetStackHandler, GetTimelineHandler,
|
||||||
|
ListDuplicatesHandler, ListTrashHandler,
|
||||||
|
ReadAssetFileHandler, ReadDerivativeHandler, RegisterAssetHandler, ResolveDuplicateHandler,
|
||||||
|
RestoreAssetHandler, SearchAssetsHandler, UpdateMetadataHandler,
|
||||||
};
|
};
|
||||||
use application::storage::IngestAssetHandler;
|
use application::storage::IngestAssetHandler;
|
||||||
use domain::ports::EventPublisher;
|
use domain::ports::EventPublisher;
|
||||||
@@ -18,13 +23,15 @@ use super::storage::StorageRepos;
|
|||||||
pub fn build(
|
pub fn build(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
storage_repos: &StorageRepos,
|
storage_repos: &StorageRepos,
|
||||||
file_storage: Arc<LocalFileStorage>,
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
event_publisher: Arc<dyn EventPublisher>,
|
event_publisher: Arc<dyn EventPublisher>,
|
||||||
) -> CatalogHandlers {
|
) -> CatalogHandlers {
|
||||||
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
|
let asset_repo = Arc::new(PostgresAssetRepository::new(pool.clone()));
|
||||||
let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone()));
|
let metadata_repo = Arc::new(PostgresAssetMetadataRepository::new(pool.clone()));
|
||||||
let derivative_repo = Arc::new(PostgresDerivativeRepository::new(pool.clone()));
|
let derivative_repo = Arc::new(PostgresDerivativeRepository::new(pool.clone()));
|
||||||
let duplicate_repo = Arc::new(PostgresDuplicateRepository::new(pool.clone()));
|
let duplicate_repo = Arc::new(PostgresDuplicateRepository::new(pool.clone()));
|
||||||
|
let sidecar_repo = Arc::new(PostgresSidecarRepository::new(pool.clone()));
|
||||||
|
let stack_repo = Arc::new(PostgresAssetStackRepository::new(pool.clone()));
|
||||||
let ingest_tx = Arc::new(PostgresIngestTransaction::new(pool.clone()));
|
let ingest_tx = Arc::new(PostgresIngestTransaction::new(pool.clone()));
|
||||||
|
|
||||||
let ingest_asset = Arc::new(IngestAssetHandler::new(
|
let ingest_asset = Arc::new(IngestAssetHandler::new(
|
||||||
@@ -44,18 +51,58 @@ pub fn build(
|
|||||||
metadata_repo.clone(),
|
metadata_repo.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let get_date_summary = Arc::new(GetDateSummaryHandler::new(asset_repo.clone()));
|
||||||
|
|
||||||
let update_metadata = Arc::new(UpdateMetadataHandler::new(
|
let update_metadata = Arc::new(UpdateMetadataHandler::new(
|
||||||
asset_repo.clone(),
|
asset_repo.clone(),
|
||||||
metadata_repo.clone(),
|
metadata_repo.clone(),
|
||||||
event_publisher.clone(),
|
event_publisher.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let volume_resolver = Arc::new(LocalVolumeFileResolver::new(
|
||||||
|
storage_repos.volume_repo.clone(),
|
||||||
|
));
|
||||||
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
|
let read_asset_file = Arc::new(ReadAssetFileHandler::new(
|
||||||
|
asset_repo.clone(),
|
||||||
|
volume_resolver,
|
||||||
|
));
|
||||||
|
|
||||||
|
let read_derivative = Arc::new(ReadDerivativeHandler::new(
|
||||||
|
derivative_repo.clone(),
|
||||||
asset_repo.clone(),
|
asset_repo.clone(),
|
||||||
file_storage.clone(),
|
file_storage.clone(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let read_derivative = Arc::new(ReadDerivativeHandler::new(derivative_repo, file_storage));
|
let search_assets = Arc::new(SearchAssetsHandler::new(asset_repo.clone()));
|
||||||
|
|
||||||
|
let delete_asset = Arc::new(DeleteAssetHandler::new(
|
||||||
|
asset_repo.clone(),
|
||||||
|
storage_repos.volume_repo.clone(),
|
||||||
|
derivative_repo.clone(),
|
||||||
|
sidecar_repo,
|
||||||
|
file_storage.clone(),
|
||||||
|
event_publisher.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let restore_asset = Arc::new(RestoreAssetHandler::new(asset_repo.clone()));
|
||||||
|
let list_trash = Arc::new(ListTrashHandler::new(asset_repo.clone()));
|
||||||
|
|
||||||
|
let list_duplicates = Arc::new(ListDuplicatesHandler::new(duplicate_repo.clone()));
|
||||||
|
let resolve_duplicate = Arc::new(ResolveDuplicateHandler::new(
|
||||||
|
duplicate_repo.clone(),
|
||||||
|
delete_asset.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
let create_stack = Arc::new(CreateStackHandler::new(
|
||||||
|
asset_repo.clone(),
|
||||||
|
stack_repo.clone(),
|
||||||
|
));
|
||||||
|
let get_stack = Arc::new(GetStackHandler::new(stack_repo.clone()));
|
||||||
|
let delete_stack = Arc::new(DeleteStackHandler::new(stack_repo.clone()));
|
||||||
|
let list_stacks = Arc::new(application::catalog::ListStacksHandler::new(
|
||||||
|
stack_repo.clone(),
|
||||||
|
));
|
||||||
|
let detect_live_photos = Arc::new(DetectLivePhotosHandler::new(asset_repo.clone(), stack_repo));
|
||||||
|
|
||||||
let register_asset = Arc::new(RegisterAssetHandler::new(
|
let register_asset = Arc::new(RegisterAssetHandler::new(
|
||||||
asset_repo,
|
asset_repo,
|
||||||
@@ -67,9 +114,21 @@ pub fn build(
|
|||||||
ingest_asset,
|
ingest_asset,
|
||||||
get_asset,
|
get_asset,
|
||||||
get_timeline,
|
get_timeline,
|
||||||
|
get_date_summary,
|
||||||
update_metadata,
|
update_metadata,
|
||||||
read_asset_file,
|
read_asset_file,
|
||||||
read_derivative,
|
read_derivative,
|
||||||
register_asset,
|
register_asset,
|
||||||
|
delete_asset,
|
||||||
|
restore_asset,
|
||||||
|
list_trash,
|
||||||
|
search_assets,
|
||||||
|
list_duplicates,
|
||||||
|
resolve_duplicate,
|
||||||
|
create_stack,
|
||||||
|
get_stack,
|
||||||
|
delete_stack,
|
||||||
|
detect_live_photos,
|
||||||
|
list_stacks,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
use adapters_auth::{BcryptPasswordHasher, JwtTokenIssuer};
|
||||||
use adapters_postgres::{PgPool, PostgresUserRepository};
|
use adapters_postgres::{PgPool, PostgresRefreshTokenRepository, PostgresUserRepository};
|
||||||
use application::identity::{GetProfileHandler, LoginUserHandler, RegisterUserHandler};
|
use application::identity::{
|
||||||
|
GetProfileHandler, LoginUserHandler, LogoutHandler, RefreshTokenHandler, RegisterUserHandler,
|
||||||
|
};
|
||||||
use domain::ports::TokenIssuer;
|
use domain::ports::TokenIssuer;
|
||||||
use presentation::state::IdentityHandlers;
|
use presentation::state::IdentityHandlers;
|
||||||
|
|
||||||
@@ -15,20 +17,31 @@ pub fn build(pool: &PgPool, jwt_secret: &str) -> IdentityServices {
|
|||||||
let user_repo = Arc::new(PostgresUserRepository::new(pool.clone()));
|
let user_repo = Arc::new(PostgresUserRepository::new(pool.clone()));
|
||||||
let hasher = Arc::new(BcryptPasswordHasher);
|
let hasher = Arc::new(BcryptPasswordHasher);
|
||||||
let issuer: Arc<JwtTokenIssuer> = Arc::new(JwtTokenIssuer::new(jwt_secret));
|
let issuer: Arc<JwtTokenIssuer> = Arc::new(JwtTokenIssuer::new(jwt_secret));
|
||||||
|
let refresh_repo = Arc::new(PostgresRefreshTokenRepository::new(pool.clone()));
|
||||||
|
|
||||||
let register = Arc::new(RegisterUserHandler::new(user_repo.clone(), hasher.clone()));
|
let register = Arc::new(RegisterUserHandler::new(user_repo.clone(), hasher.clone()));
|
||||||
let login = Arc::new(LoginUserHandler::new(
|
let login = Arc::new(LoginUserHandler::new(
|
||||||
user_repo.clone(),
|
user_repo.clone(),
|
||||||
hasher,
|
hasher,
|
||||||
issuer.clone(),
|
issuer.clone(),
|
||||||
|
refresh_repo.clone(),
|
||||||
));
|
));
|
||||||
let get_profile = Arc::new(GetProfileHandler::new(user_repo));
|
let get_profile = Arc::new(GetProfileHandler::new(user_repo.clone()));
|
||||||
|
let refresh = Arc::new(RefreshTokenHandler::new(
|
||||||
|
refresh_repo.clone(),
|
||||||
|
user_repo,
|
||||||
|
issuer.clone(),
|
||||||
|
));
|
||||||
|
let logout = Arc::new(LogoutHandler::new(refresh_repo.clone()));
|
||||||
|
|
||||||
IdentityServices {
|
IdentityServices {
|
||||||
handlers: IdentityHandlers {
|
handlers: IdentityHandlers {
|
||||||
register,
|
register,
|
||||||
login,
|
login,
|
||||||
get_profile,
|
get_profile,
|
||||||
|
refresh,
|
||||||
|
logout,
|
||||||
|
refresh_token_repo: refresh_repo,
|
||||||
},
|
},
|
||||||
token_issuer: issuer,
|
token_issuer: issuer,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use adapters_postgres::{
|
|||||||
PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository,
|
PgPool, PostgresAlbumRepository, PostgresAssetRepository, PostgresTagRepository,
|
||||||
};
|
};
|
||||||
use application::organization::{
|
use application::organization::{
|
||||||
CreateAlbumHandler, GetAlbumHandler, ManageAlbumEntriesHandler, TagAssetHandler,
|
CreateAlbumHandler, DeleteAlbumHandler, GetAlbumHandler, ListAlbumsHandler,
|
||||||
|
ManageAlbumEntriesHandler, TagAssetHandler, UpdateAlbumHandler,
|
||||||
};
|
};
|
||||||
use presentation::state::OrganizationHandlers;
|
use presentation::state::OrganizationHandlers;
|
||||||
|
|
||||||
@@ -15,13 +16,19 @@ pub fn build(pool: &PgPool) -> OrganizationHandlers {
|
|||||||
|
|
||||||
let create_album = Arc::new(CreateAlbumHandler::new(album_repo.clone()));
|
let create_album = Arc::new(CreateAlbumHandler::new(album_repo.clone()));
|
||||||
let get_album = Arc::new(GetAlbumHandler::new(album_repo.clone()));
|
let get_album = Arc::new(GetAlbumHandler::new(album_repo.clone()));
|
||||||
|
let list_albums = Arc::new(ListAlbumsHandler::new(album_repo.clone()));
|
||||||
|
let update_album = Arc::new(UpdateAlbumHandler::new(album_repo.clone()));
|
||||||
|
let delete_album = Arc::new(DeleteAlbumHandler::new(album_repo.clone()));
|
||||||
let manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
|
let manage_album_entries = Arc::new(ManageAlbumEntriesHandler::new(album_repo));
|
||||||
let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo));
|
let tag_asset = Arc::new(TagAssetHandler::new(asset_repo, tag_repo));
|
||||||
|
|
||||||
OrganizationHandlers {
|
OrganizationHandlers {
|
||||||
create_album,
|
create_album,
|
||||||
|
delete_album,
|
||||||
get_album,
|
get_album,
|
||||||
|
list_albums,
|
||||||
manage_album_entries,
|
manage_album_entries,
|
||||||
|
update_album,
|
||||||
tag_asset,
|
tag_asset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use adapters_postgres::{
|
|||||||
};
|
};
|
||||||
use application::processing::{
|
use application::processing::{
|
||||||
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
CompleteJobHandler, ConfigurePipelineHandler, EnqueueJobHandler, FailJobHandler,
|
||||||
ManagePluginHandler, ReportBatchProgressHandler, StartJobHandler,
|
ListJobsHandler, ListPipelinesHandler, ListPluginsHandler, ManagePluginHandler,
|
||||||
|
ReportBatchProgressHandler, StartJobHandler,
|
||||||
};
|
};
|
||||||
use domain::ports::EventPublisher;
|
use domain::ports::EventPublisher;
|
||||||
use presentation::state::ProcessingHandlers;
|
use presentation::state::ProcessingHandlers;
|
||||||
@@ -32,8 +33,11 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
|
|||||||
batch_repo.clone(),
|
batch_repo.clone(),
|
||||||
event_publisher,
|
event_publisher,
|
||||||
));
|
));
|
||||||
|
let list_jobs = Arc::new(ListJobsHandler::new(job_repo.clone()));
|
||||||
let batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
|
let batch_progress = Arc::new(ReportBatchProgressHandler::new(batch_repo, job_repo));
|
||||||
let manage_plugin = Arc::new(ManagePluginHandler::new(plugin_repo.clone()));
|
let manage_plugin = Arc::new(ManagePluginHandler::new(plugin_repo.clone()));
|
||||||
|
let list_plugins = Arc::new(ListPluginsHandler::new(plugin_repo.clone()));
|
||||||
|
let list_pipelines = Arc::new(ListPipelinesHandler::new(pipeline_repo.clone()));
|
||||||
let configure_pipeline = Arc::new(ConfigurePipelineHandler::new(pipeline_repo, plugin_repo));
|
let configure_pipeline = Arc::new(ConfigurePipelineHandler::new(pipeline_repo, plugin_repo));
|
||||||
|
|
||||||
ProcessingHandlers {
|
ProcessingHandlers {
|
||||||
@@ -41,8 +45,11 @@ pub fn build(pool: &PgPool, event_publisher: Arc<dyn EventPublisher>) -> Process
|
|||||||
start_job,
|
start_job,
|
||||||
complete_job,
|
complete_job,
|
||||||
fail_job,
|
fail_job,
|
||||||
|
list_jobs,
|
||||||
batch_progress,
|
batch_progress,
|
||||||
manage_plugin,
|
manage_plugin,
|
||||||
|
list_plugins,
|
||||||
configure_pipeline,
|
configure_pipeline,
|
||||||
|
list_pipelines,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ use adapters_postgres::{
|
|||||||
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
|
PgPool, PostgresLibraryPathRepository, PostgresQuotaRepository,
|
||||||
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
|
PostgresStorageVolumeRepository, PostgresUsageLedgerRepository,
|
||||||
};
|
};
|
||||||
use application::storage::{CheckQuotaHandler, RegisterLibraryPathHandler, RegisterVolumeHandler};
|
use application::storage::{
|
||||||
|
CheckQuotaHandler, DeleteLibraryPathHandler, DeleteVolumeHandler, ListAllLibraryPathsHandler,
|
||||||
|
ListIngestPathsHandler, ListVolumesHandler, RegisterLibraryPathHandler,
|
||||||
|
RegisterVolumeHandler,
|
||||||
|
};
|
||||||
use presentation::state::StorageHandlers;
|
use presentation::state::StorageHandlers;
|
||||||
|
|
||||||
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
|
/// Shared storage repos needed by other bounded contexts (catalog ingest, etc.).
|
||||||
pub struct StorageRepos {
|
pub struct StorageRepos {
|
||||||
pub path_repo: Arc<PostgresLibraryPathRepository>,
|
pub path_repo: Arc<dyn domain::ports::LibraryPathRepository>,
|
||||||
|
pub volume_repo: Arc<dyn domain::ports::StorageVolumeRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
||||||
@@ -18,20 +23,33 @@ pub fn build(pool: &PgPool) -> (StorageRepos, StorageHandlers) {
|
|||||||
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
|
let quota_repo = Arc::new(PostgresQuotaRepository::new(pool.clone()));
|
||||||
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
|
let ledger_repo = Arc::new(PostgresUsageLedgerRepository::new(pool.clone()));
|
||||||
|
|
||||||
|
let list_volumes = Arc::new(ListVolumesHandler::new(volume_repo.clone()));
|
||||||
let register_volume = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
|
let register_volume = Arc::new(RegisterVolumeHandler::new(volume_repo.clone()));
|
||||||
|
let delete_volume = Arc::new(DeleteVolumeHandler::new(volume_repo.clone()));
|
||||||
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
|
let register_library_path = Arc::new(RegisterLibraryPathHandler::new(
|
||||||
volume_repo,
|
volume_repo.clone(),
|
||||||
path_repo.clone(),
|
path_repo.clone(),
|
||||||
));
|
));
|
||||||
|
let list_ingest_paths = Arc::new(ListIngestPathsHandler::new(path_repo.clone()));
|
||||||
|
let list_all_library_paths = Arc::new(ListAllLibraryPathsHandler::new(path_repo.clone()));
|
||||||
|
let delete_library_path = Arc::new(DeleteLibraryPathHandler::new(path_repo.clone()));
|
||||||
let check_quota = Arc::new(CheckQuotaHandler::new(quota_repo, ledger_repo));
|
let check_quota = Arc::new(CheckQuotaHandler::new(quota_repo, ledger_repo));
|
||||||
|
|
||||||
let handlers = StorageHandlers {
|
let handlers = StorageHandlers {
|
||||||
register_volume,
|
register_volume,
|
||||||
|
delete_volume,
|
||||||
|
list_volumes,
|
||||||
register_library_path,
|
register_library_path,
|
||||||
|
list_ingest_paths,
|
||||||
|
list_all_library_paths,
|
||||||
|
delete_library_path,
|
||||||
check_quota,
|
check_quota,
|
||||||
};
|
};
|
||||||
|
|
||||||
let repos = StorageRepos { path_repo };
|
let repos = StorageRepos {
|
||||||
|
path_repo,
|
||||||
|
volume_repo,
|
||||||
|
};
|
||||||
|
|
||||||
(repos, handlers)
|
(repos, handlers)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ uuid = { workspace = true }
|
|||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
email_address = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ pub struct Asset {
|
|||||||
pub is_processed: bool,
|
pub is_processed: bool,
|
||||||
pub owner_user_id: SystemId,
|
pub owner_user_id: SystemId,
|
||||||
pub created_at: DateTimeStamp,
|
pub created_at: DateTimeStamp,
|
||||||
|
pub deleted_at: Option<DateTimeStamp>,
|
||||||
|
pub deleted_by: Option<SystemId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Asset {
|
impl Asset {
|
||||||
@@ -46,12 +48,40 @@ impl Asset {
|
|||||||
is_processed: false,
|
is_processed: false,
|
||||||
owner_user_id: owner,
|
owner_user_id: owner,
|
||||||
created_at: DateTimeStamp::now(),
|
created_at: DateTimeStamp::now(),
|
||||||
|
deleted_at: None,
|
||||||
|
deleted_by: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn mark_processed(&mut self) {
|
pub fn mark_processed(&mut self) {
|
||||||
self.is_processed = true;
|
self.is_processed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_deleted(&self) -> bool {
|
||||||
|
self.deleted_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trash(&mut self, by: SystemId) {
|
||||||
|
self.deleted_at = Some(DateTimeStamp::now());
|
||||||
|
self.deleted_by = Some(by);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore(&mut self) {
|
||||||
|
self.deleted_at = None;
|
||||||
|
self.deleted_by = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AssetFilters ---
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct AssetFilters {
|
||||||
|
pub asset_type: Option<AssetType>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub date_from: Option<DateTimeStamp>,
|
||||||
|
pub date_to: Option<DateTimeStamp>,
|
||||||
|
pub is_processed: Option<bool>,
|
||||||
|
pub tag_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AssetMetadata ---
|
// --- AssetMetadata ---
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::entities::{
|
use super::entities::{
|
||||||
Asset, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile, DuplicateGroup,
|
Asset, AssetFilters, AssetMetadata, AssetStack, DerivativeAsset, DerivativeProfile,
|
||||||
MetadataSource,
|
DuplicateGroup, MetadataSource,
|
||||||
};
|
};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::common::value_objects::{Checksum, StructuredData, SystemId};
|
use crate::common::value_objects::{Checksum, StructuredData, SystemId};
|
||||||
@@ -19,8 +19,42 @@ pub trait AssetRepository: Send + Sync {
|
|||||||
limit: u32,
|
limit: u32,
|
||||||
offset: u32,
|
offset: u32,
|
||||||
) -> Result<Vec<Asset>, DomainError>;
|
) -> Result<Vec<Asset>, DomainError>;
|
||||||
|
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
filters: &AssetFilters,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Asset>, DomainError>;
|
||||||
|
async fn count_search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
filters: &AssetFilters,
|
||||||
|
) -> Result<u64, DomainError>;
|
||||||
|
async fn date_summary(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError>;
|
||||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
async fn soft_delete(
|
||||||
|
&self,
|
||||||
|
id: &SystemId,
|
||||||
|
deleted_by: &SystemId,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn restore(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
async fn find_trashed_before(
|
||||||
|
&self,
|
||||||
|
cutoff: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<Vec<Asset>, DomainError>;
|
||||||
|
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError>;
|
||||||
|
async fn find_trashed_by_owner(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Asset>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- AssetMetadataRepository ---
|
// --- AssetMetadataRepository ---
|
||||||
@@ -28,6 +62,10 @@ pub trait AssetRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AssetMetadataRepository: Send + Sync {
|
pub trait AssetMetadataRepository: Send + Sync {
|
||||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetMetadata>, DomainError>;
|
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetMetadata>, DomainError>;
|
||||||
|
async fn find_by_assets(
|
||||||
|
&self,
|
||||||
|
asset_ids: &[SystemId],
|
||||||
|
) -> Result<Vec<AssetMetadata>, DomainError>;
|
||||||
async fn find_by_asset_and_source(
|
async fn find_by_asset_and_source(
|
||||||
&self,
|
&self,
|
||||||
asset_id: &SystemId,
|
asset_id: &SystemId,
|
||||||
@@ -46,6 +84,7 @@ pub trait AssetMetadataRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait AssetStackRepository: Send + Sync {
|
pub trait AssetStackRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<AssetStack>, DomainError>;
|
||||||
|
async fn find_by_owner(&self, owner_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
|
||||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
|
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetStack>, DomainError>;
|
||||||
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
|
async fn save(&self, stack: &AssetStack) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
@@ -71,7 +110,11 @@ pub trait DerivativeRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait DuplicateRepository: Send + Sync {
|
pub trait DuplicateRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<DuplicateGroup>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<DuplicateGroup>, DomainError>;
|
||||||
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError>;
|
async fn find_unresolved(
|
||||||
|
&self,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<DuplicateGroup>, DomainError>;
|
||||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError>;
|
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError>;
|
||||||
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>;
|
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,13 @@ pub enum DomainError {
|
|||||||
#[error("Internal error: {0}")]
|
#[error("Internal error: {0}")]
|
||||||
Internal(String),
|
Internal(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait OptionExt<T> {
|
||||||
|
fn or_not_found(self, entity: &str) -> Result<T, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> OptionExt<T> for Option<T> {
|
||||||
|
fn or_not_found(self, entity: &str) -> Result<T, DomainError> {
|
||||||
|
self.ok_or_else(|| DomainError::NotFound(format!("{entity} not found")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,4 +59,27 @@ pub enum DomainEvent {
|
|||||||
error: String,
|
error: String,
|
||||||
timestamp: DateTimeStamp,
|
timestamp: DateTimeStamp,
|
||||||
},
|
},
|
||||||
|
UserCreated {
|
||||||
|
user_id: SystemId,
|
||||||
|
timestamp: DateTimeStamp,
|
||||||
|
},
|
||||||
|
UserDeleted {
|
||||||
|
user_id: SystemId,
|
||||||
|
timestamp: DateTimeStamp,
|
||||||
|
},
|
||||||
|
AlbumCreated {
|
||||||
|
album_id: SystemId,
|
||||||
|
creator_id: SystemId,
|
||||||
|
timestamp: DateTimeStamp,
|
||||||
|
},
|
||||||
|
TagCreated {
|
||||||
|
tag_id: SystemId,
|
||||||
|
asset_id: SystemId,
|
||||||
|
timestamp: DateTimeStamp,
|
||||||
|
},
|
||||||
|
DuplicateDetected {
|
||||||
|
group_id: SystemId,
|
||||||
|
asset_ids: Vec<SystemId>,
|
||||||
|
timestamp: DateTimeStamp,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ pub struct Email(String);
|
|||||||
impl Email {
|
impl Email {
|
||||||
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
let value = value.into().trim().to_lowercase();
|
let value = value.into().trim().to_lowercase();
|
||||||
if value.is_empty() || !value.contains('@') {
|
if !email_address::EmailAddress::is_valid(&value) {
|
||||||
return Err(DomainError::Validation("Invalid email address".to_string()));
|
return Err(DomainError::Validation("Invalid email address".into()));
|
||||||
}
|
}
|
||||||
Ok(Self(value))
|
Ok(Self(value))
|
||||||
}
|
}
|
||||||
|
|||||||
26
crates/domain/src/common/value_objects/mime_type.rs
Normal file
26
crates/domain/src/common/value_objects/mime_type.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use crate::common::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct MimeType(String);
|
||||||
|
|
||||||
|
impl MimeType {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let value = value.into();
|
||||||
|
if !value.contains('/') || value.len() < 3 {
|
||||||
|
return Err(DomainError::Validation(format!(
|
||||||
|
"Invalid MIME type: {value}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for MimeType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,20 @@ mod checksum;
|
|||||||
mod date_time_stamp;
|
mod date_time_stamp;
|
||||||
mod email;
|
mod email;
|
||||||
pub mod filter_criteria;
|
pub mod filter_criteria;
|
||||||
|
mod mime_type;
|
||||||
mod password;
|
mod password;
|
||||||
|
mod relative_path;
|
||||||
mod structured_data;
|
mod structured_data;
|
||||||
mod system_id;
|
mod system_id;
|
||||||
|
mod username;
|
||||||
|
|
||||||
pub use checksum::Checksum;
|
pub use checksum::Checksum;
|
||||||
pub use date_time_stamp::DateTimeStamp;
|
pub use date_time_stamp::DateTimeStamp;
|
||||||
pub use email::Email;
|
pub use email::Email;
|
||||||
pub use filter_criteria::{FilterCondition, FilterCriteria, FilterOperator};
|
pub use filter_criteria::{FilterCondition, FilterCriteria, FilterOperator};
|
||||||
|
pub use mime_type::MimeType;
|
||||||
pub use password::PasswordHash;
|
pub use password::PasswordHash;
|
||||||
|
pub use relative_path::RelativePath;
|
||||||
pub use structured_data::{MetadataValue, StructuredData};
|
pub use structured_data::{MetadataValue, StructuredData};
|
||||||
pub use system_id::SystemId;
|
pub use system_id::SystemId;
|
||||||
|
pub use username::Username;
|
||||||
|
|||||||
31
crates/domain/src/common/value_objects/relative_path.rs
Normal file
31
crates/domain/src/common/value_objects/relative_path.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use crate::common::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct RelativePath(String);
|
||||||
|
|
||||||
|
impl RelativePath {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err(DomainError::Validation("Path must not be empty".into()));
|
||||||
|
}
|
||||||
|
if value.contains("..") {
|
||||||
|
return Err(DomainError::Validation("Path must not contain '..'".into()));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn filename(&self) -> &str {
|
||||||
|
self.0.rsplit('/').next().unwrap_or(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for RelativePath {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
crates/domain/src/common/value_objects/username.rs
Normal file
34
crates/domain/src/common/value_objects/username.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use crate::common::errors::DomainError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct Username(String);
|
||||||
|
|
||||||
|
impl Username {
|
||||||
|
pub fn new(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.len() < 2 || value.len() > 64 {
|
||||||
|
return Err(DomainError::Validation(
|
||||||
|
"Username must be 2-64 characters".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !value
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
{
|
||||||
|
return Err(DomainError::Validation(
|
||||||
|
"Username may only contain alphanumeric, underscore, dash, or dot".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Username {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::common::value_objects::{Email, PasswordHash, SystemId};
|
use crate::common::value_objects::{DateTimeStamp, Email, PasswordHash, SystemId};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
@@ -126,6 +126,7 @@ pub struct User {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub email: Email,
|
pub email: Email,
|
||||||
pub password_hash: PasswordHash,
|
pub password_hash: PasswordHash,
|
||||||
|
pub role: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,9 +137,43 @@ impl User {
|
|||||||
username: username.into(),
|
username: username.into(),
|
||||||
email,
|
email,
|
||||||
password_hash,
|
password_hash,
|
||||||
|
role: "user".to_string(),
|
||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_admin(&self) -> bool {
|
||||||
|
self.role == "admin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- RefreshToken ---
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RefreshToken {
|
||||||
|
pub token_id: SystemId,
|
||||||
|
pub user_id: SystemId,
|
||||||
|
pub token_hash: String,
|
||||||
|
pub expires_at: DateTimeStamp,
|
||||||
|
pub revoked: bool,
|
||||||
|
pub created_at: DateTimeStamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RefreshToken {
|
||||||
|
pub fn new(user_id: SystemId, token_hash: String, expires_at: DateTimeStamp) -> Self {
|
||||||
|
Self {
|
||||||
|
token_id: SystemId::new(),
|
||||||
|
user_id,
|
||||||
|
token_hash,
|
||||||
|
expires_at,
|
||||||
|
revoked: false,
|
||||||
|
created_at: DateTimeStamp::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_valid(&self) -> bool {
|
||||||
|
!self.revoked && *self.expires_at.as_datetime() > Utc::now()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Group ---
|
// --- Group ---
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use super::entities::{Group, Role, User};
|
use super::entities::{Group, RefreshToken, Role, User};
|
||||||
use crate::common::errors::DomainError;
|
use crate::common::errors::DomainError;
|
||||||
use crate::common::value_objects::{Email, PasswordHash, SystemId};
|
use crate::common::value_objects::{Email, PasswordHash, SystemId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -12,6 +12,7 @@ pub trait UserRepository: Send + Sync {
|
|||||||
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
|
async fn find_by_username(&self, username: &str) -> Result<Option<User>, DomainError>;
|
||||||
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
async fn save(&self, user: &User) -> Result<(), DomainError>;
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
async fn count(&self) -> Result<u64, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- RoleRepository ---
|
// --- RoleRepository ---
|
||||||
@@ -35,6 +36,16 @@ pub trait GroupRepository: Send + Sync {
|
|||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RefreshTokenRepository ---
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait RefreshTokenRepository: Send + Sync {
|
||||||
|
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError>;
|
||||||
|
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError>;
|
||||||
|
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- Auth ---
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ pub trait JobRepository: Send + Sync {
|
|||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError>;
|
||||||
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError>;
|
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError>;
|
||||||
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>;
|
||||||
|
async fn find_all(
|
||||||
|
&self,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Job>, DomainError>;
|
||||||
|
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError>;
|
||||||
async fn save(&self, job: &Job) -> Result<(), DomainError>;
|
async fn save(&self, job: &Job) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +34,7 @@ pub trait JobBatchRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PluginRepository: Send + Sync {
|
pub trait PluginRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<Plugin>, DomainError>;
|
||||||
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
|
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError>;
|
||||||
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
|
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
@@ -36,6 +44,7 @@ pub trait PluginRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait PipelineRepository: Send + Sync {
|
pub trait PipelineRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
||||||
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError>;
|
||||||
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
|
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub trait StorageVolumeRepository: Send + Sync {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait LibraryPathRepository: Send + Sync {
|
pub trait LibraryPathRepository: Send + Sync {
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError>;
|
||||||
|
async fn find_all(&self) -> Result<Vec<LibraryPath>, DomainError>;
|
||||||
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>;
|
||||||
async fn find_ingest_destinations(
|
async fn find_ingest_destinations(
|
||||||
&self,
|
&self,
|
||||||
@@ -84,6 +85,23 @@ pub trait IngestTransaction: Send + Sync {
|
|||||||
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
|
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- VolumeFileResolver ---
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VolumeFileResolver: Send + Sync {
|
||||||
|
async fn open_by_volume(
|
||||||
|
&self,
|
||||||
|
volume_id: &SystemId,
|
||||||
|
relative_path: &str,
|
||||||
|
) -> Result<(DataStream, u64), DomainError>;
|
||||||
|
|
||||||
|
async fn read_by_volume(
|
||||||
|
&self,
|
||||||
|
volume_id: &SystemId,
|
||||||
|
relative_path: &str,
|
||||||
|
) -> Result<Bytes, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
// --- FileStoragePort ---
|
// --- FileStoragePort ---
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -97,6 +115,7 @@ pub struct FileEntry {
|
|||||||
pub trait FileStoragePort: Send + Sync {
|
pub trait FileStoragePort: Send + Sync {
|
||||||
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>;
|
async fn store_file(&self, path: &str, data: Bytes) -> Result<(), DomainError>;
|
||||||
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError>;
|
async fn read_file(&self, path: &str) -> Result<Bytes, DomainError>;
|
||||||
|
async fn open_file(&self, path: &str) -> Result<(DataStream, u64), DomainError>;
|
||||||
async fn delete_file(&self, path: &str) -> Result<(), DomainError>;
|
async fn delete_file(&self, path: &str) -> Result<(), DomainError>;
|
||||||
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError>;
|
async fn list_directory(&self, path: &str) -> Result<Vec<FileEntry>, DomainError>;
|
||||||
async fn file_exists(&self, path: &str) -> Result<bool, DomainError>;
|
async fn file_exists(&self, path: &str) -> Result<bool, DomainError>;
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
use crate::state::AppState;
|
use crate::{middleware::auth::extract_bearer_token, state::AppState};
|
||||||
use axum::{
|
use axum::{extract::FromRequestParts, http::request::Parts, response::Response};
|
||||||
Json,
|
|
||||||
extract::FromRequestParts,
|
|
||||||
http::{StatusCode, request::Parts},
|
|
||||||
response::{IntoResponse, Response},
|
|
||||||
};
|
|
||||||
use domain::value_objects::SystemId;
|
use domain::value_objects::SystemId;
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
pub struct JwtClaims {
|
pub struct JwtClaims {
|
||||||
pub user_id: SystemId,
|
pub user_id: SystemId,
|
||||||
@@ -20,30 +14,13 @@ impl FromRequestParts<AppState> for JwtClaims {
|
|||||||
parts: &mut Parts,
|
parts: &mut Parts,
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
) -> Result<Self, Self::Rejection> {
|
) -> Result<Self, Self::Rejection> {
|
||||||
let auth_header = parts
|
let token = extract_bearer_token(&parts.headers)?;
|
||||||
.headers
|
|
||||||
.get(axum::http::header::AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
Json(json!({ "error": "Missing Authorization header" })),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
|
||||||
(
|
|
||||||
StatusCode::UNAUTHORIZED,
|
|
||||||
Json(json!({ "error": "Invalid Authorization format" })),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
|
let (user_id, role) = state.token_issuer.verify(token).await.map_err(|_| {
|
||||||
|
use axum::{Json, http::StatusCode, response::IntoResponse};
|
||||||
(
|
(
|
||||||
StatusCode::UNAUTHORIZED,
|
StatusCode::UNAUTHORIZED,
|
||||||
Json(json!({ "error": "Invalid or expired token" })),
|
Json(serde_json::json!({ "error": "Invalid or expired token" })),
|
||||||
)
|
)
|
||||||
.into_response()
|
.into_response()
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
use crate::{errors::AppError, extractors::JwtClaims, state::AppState};
|
||||||
|
use api_types::requests::UpdateAlbumRequest;
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{AlbumEntryRequest, CreateAlbumRequest},
|
requests::{AlbumEntryRequest, CreateAlbumRequest},
|
||||||
responses::AlbumResponse,
|
responses::AlbumResponse,
|
||||||
};
|
};
|
||||||
use application::organization::{
|
use application::organization::{
|
||||||
AlbumAction, CreateAlbumCommand, GetAlbumQuery, ManageAlbumEntriesCommand,
|
AlbumAction, CreateAlbumCommand, DeleteAlbumCommand, GetAlbumQuery, ListAlbumsQuery,
|
||||||
|
ManageAlbumEntriesCommand, UpdateAlbumCommand,
|
||||||
};
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
@@ -13,6 +15,35 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use domain::value_objects::SystemId;
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/albums",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "List of albums", body = Vec<AlbumResponse>),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_albums(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<Vec<AlbumResponse>>, AppError> {
|
||||||
|
let query = ListAlbumsQuery {
|
||||||
|
user_id: claims.user_id,
|
||||||
|
};
|
||||||
|
let albums = state.organization.list_albums.execute(query).await?;
|
||||||
|
let resp = albums.iter().map(AlbumResponse::from_domain).collect();
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/albums",
|
||||||
|
request_body = CreateAlbumRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Album created", body = AlbumResponse),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn create_album(
|
pub async fn create_album(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -29,6 +60,15 @@ pub async fn create_album(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/albums/{id}",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Album details", body = AlbumResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn get_album(
|
pub async fn get_album(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -42,6 +82,55 @@ pub async fn get_album(
|
|||||||
Ok(Json(AlbumResponse::from_domain(&album)))
|
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put, path = "/api/v1/albums/{id}",
|
||||||
|
request_body = UpdateAlbumRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Album updated", body = AlbumResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn update_album(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((album_id,)): Path<(uuid::Uuid,)>,
|
||||||
|
Json(req): Json<UpdateAlbumRequest>,
|
||||||
|
) -> Result<Json<AlbumResponse>, AppError> {
|
||||||
|
let cmd = UpdateAlbumCommand {
|
||||||
|
album_id: SystemId::from_uuid(album_id),
|
||||||
|
user_id: claims.user_id,
|
||||||
|
title: req.title,
|
||||||
|
description: req.description,
|
||||||
|
};
|
||||||
|
let album = state.organization.update_album.execute(cmd).await?;
|
||||||
|
Ok(Json(AlbumResponse::from_domain(&album)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_album(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((album_id,)): Path<(uuid::Uuid,)>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let cmd = DeleteAlbumCommand {
|
||||||
|
album_id: SystemId::from_uuid(album_id),
|
||||||
|
user_id: claims.user_id,
|
||||||
|
};
|
||||||
|
state.organization.delete_album.execute(cmd).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/albums/{id}/entries",
|
||||||
|
request_body = AlbumEntryRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Album ID")),
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Entry added", body = AlbumResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn add_entry(
|
pub async fn add_entry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -56,9 +145,24 @@ pub async fn add_entry(
|
|||||||
user_id: claims.user_id,
|
user_id: claims.user_id,
|
||||||
};
|
};
|
||||||
let album = state.organization.manage_album_entries.execute(cmd).await?;
|
let album = state.organization.manage_album_entries.execute(cmd).await?;
|
||||||
Ok((StatusCode::OK, Json(AlbumResponse::from_domain(&album))))
|
Ok((
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(AlbumResponse::from_domain(&album)),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/v1/albums/{id}/entries/{asset_id}",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(
|
||||||
|
("id" = uuid::Uuid, Path, description = "Album ID"),
|
||||||
|
("asset_id" = uuid::Uuid, Path, description = "Asset ID")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Entry removed", body = AlbumResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn remove_entry(
|
pub async fn remove_entry(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ use crate::{
|
|||||||
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
|
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
extractors::{JwtClaims, UploadedAsset},
|
extractors::{JwtClaims, UploadedAsset},
|
||||||
|
parsers,
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{RegisterAssetRequest, TagAssetRequest},
|
requests::{RegisterAssetRequest, TagAssetRequest},
|
||||||
responses::{AssetResponse, IngestResponse, TagResponse, TimelineResponse},
|
responses::{
|
||||||
|
AssetResponse, DateCountEntry, DateSummaryResponse, IngestResponse, TagResponse,
|
||||||
|
TimelineResponse,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use application::{
|
use application::{
|
||||||
catalog::{
|
catalog::{
|
||||||
GetAssetQuery, GetTimelineQuery, ReadAssetFileQuery, ReadDerivativeQuery,
|
DeleteAssetCommand, GetAssetQuery, GetDateSummaryQuery, GetTimelineQuery, ListTrashQuery,
|
||||||
RegisterAssetCommand, UpdateMetadataCommand,
|
ReadAssetFileQuery, ReadDerivativeQuery, RegisterAssetCommand, RestoreAssetCommand,
|
||||||
|
SearchAssetsQuery, UpdateMetadataCommand,
|
||||||
},
|
},
|
||||||
organization::TagAssetCommand,
|
organization::TagAssetCommand,
|
||||||
storage::IngestAssetCommand,
|
storage::IngestAssetCommand,
|
||||||
@@ -24,9 +29,9 @@ use axum::{
|
|||||||
response::Response,
|
response::Response,
|
||||||
};
|
};
|
||||||
use domain::{
|
use domain::{
|
||||||
catalog::entities::AssetType,
|
catalog::entities::AssetFilters,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
@@ -35,6 +40,111 @@ pub struct TimelineParams {
|
|||||||
pub offset: Option<u32>,
|
pub offset: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct SearchParams {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub asset_type: Option<String>,
|
||||||
|
pub mime_type: Option<String>,
|
||||||
|
pub date_from: Option<String>,
|
||||||
|
pub date_to: Option<String>,
|
||||||
|
pub is_processed: Option<bool>,
|
||||||
|
pub tag: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/assets",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(
|
||||||
|
("type" = Option<String>, Query, description = "Asset type filter"),
|
||||||
|
("mime_type" = Option<String>, Query, description = "MIME type filter"),
|
||||||
|
("date_from" = Option<String>, Query, description = "Start date (YYYY-MM-DD)"),
|
||||||
|
("date_to" = Option<String>, Query, description = "End date (YYYY-MM-DD)"),
|
||||||
|
("is_processed" = Option<bool>, Query, description = "Processed filter"),
|
||||||
|
("tag" = Option<String>, Query, description = "Tag name filter"),
|
||||||
|
("limit" = Option<u32>, Query, description = "Page size"),
|
||||||
|
("offset" = Option<u32>, Query, description = "Page offset")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Search results", body = TimelineResponse),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn search_assets(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Query(params): Query<SearchParams>,
|
||||||
|
) -> Result<Json<TimelineResponse>, AppError> {
|
||||||
|
let asset_type = params
|
||||||
|
.asset_type
|
||||||
|
.as_deref()
|
||||||
|
.map(parsers::asset_type)
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let date_from = params
|
||||||
|
.date_from
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| {
|
||||||
|
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_from".into())))?;
|
||||||
|
d.and_hms_opt(0, 0, 0)
|
||||||
|
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
|
||||||
|
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_from".into())))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let date_to = params
|
||||||
|
.date_to
|
||||||
|
.as_deref()
|
||||||
|
.map(|s| {
|
||||||
|
let d = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
|
||||||
|
.map_err(|_| AppError::from(DomainError::Validation("Invalid date_to".into())))?;
|
||||||
|
d.and_hms_opt(23, 59, 59)
|
||||||
|
.map(|dt| DateTimeStamp::from_datetime(dt.and_utc()))
|
||||||
|
.ok_or_else(|| AppError::from(DomainError::Validation("Invalid date_to".into())))
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
|
||||||
|
let filters = AssetFilters {
|
||||||
|
asset_type,
|
||||||
|
mime_type: params.mime_type,
|
||||||
|
date_from,
|
||||||
|
date_to,
|
||||||
|
is_processed: params.is_processed,
|
||||||
|
tag_name: params.tag,
|
||||||
|
};
|
||||||
|
|
||||||
|
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
|
||||||
|
let query = SearchAssetsQuery {
|
||||||
|
owner_id: claims.user_id,
|
||||||
|
filters,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
let result = state.catalog.search_assets.execute(query).await?;
|
||||||
|
let assets = result
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(TimelineResponse {
|
||||||
|
assets,
|
||||||
|
total: result.total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/assets/ingest",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
request_body(content_type = "multipart/form-data"),
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Asset ingested", body = IngestResponse),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn ingest(
|
pub async fn ingest(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -62,6 +172,15 @@ pub async fn ingest(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/assets/{id}",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Asset details", body = AssetResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn get_asset(
|
pub async fn get_asset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -75,6 +194,18 @@ pub async fn get_asset(
|
|||||||
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
|
Ok(Json(AssetResponse::from_domain(&asset, &metadata)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/assets/timeline",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(
|
||||||
|
("limit" = Option<u32>, Query, description = "Page size"),
|
||||||
|
("offset" = Option<u32>, Query, description = "Page offset")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Timeline view", body = TimelineResponse),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn timeline(
|
pub async fn timeline(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -86,15 +217,47 @@ pub async fn timeline(
|
|||||||
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
|
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
|
||||||
offset: params.offset.unwrap_or(0),
|
offset: params.offset.unwrap_or(0),
|
||||||
};
|
};
|
||||||
let results = state.catalog.get_timeline.execute(query).await?;
|
let result = state.catalog.get_timeline.execute(query).await?;
|
||||||
let total = results.len();
|
let assets = result
|
||||||
let assets = results
|
.items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
|
.map(|(asset, meta)| AssetResponse::from_domain(asset, meta))
|
||||||
.collect();
|
.collect();
|
||||||
Ok(Json(TimelineResponse { assets, total }))
|
Ok(Json(TimelineResponse {
|
||||||
|
assets,
|
||||||
|
total: result.total,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn date_summary(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<Json<DateSummaryResponse>, AppError> {
|
||||||
|
let query = GetDateSummaryQuery {
|
||||||
|
owner_id: claims.user_id,
|
||||||
|
};
|
||||||
|
let entries = state.catalog.get_date_summary.execute(query).await?;
|
||||||
|
Ok(Json(DateSummaryResponse {
|
||||||
|
dates: entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| DateCountEntry {
|
||||||
|
date: e.date.to_string(),
|
||||||
|
count: e.count,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put, path = "/api/v1/assets/{id}/metadata",
|
||||||
|
request_body = api_types::requests::UpdateMetadataRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Metadata updated"),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn update_metadata(
|
pub async fn update_metadata(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -115,28 +278,48 @@ pub async fn update_metadata(
|
|||||||
Ok(Json(serde_json::json!({ "status": "updated" })))
|
Ok(Json(serde_json::json!({ "status": "updated" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/assets/{id}/file",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "File content", content_type = "application/octet-stream"),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn serve_file(
|
pub async fn serve_file(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let query = ReadAssetFileQuery {
|
let query = ReadAssetFileQuery {
|
||||||
asset_id: SystemId::from_uuid(asset_id),
|
asset_id: SystemId::from_uuid(asset_id),
|
||||||
|
caller_id: claims.user_id,
|
||||||
};
|
};
|
||||||
let result = state.catalog.read_asset_file.execute(query).await?;
|
let result = state.catalog.read_asset_file.execute(query).await?;
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &result.mime_type)
|
.header(header::CONTENT_TYPE, &result.mime_type)
|
||||||
.header(header::CONTENT_LENGTH, result.data.len())
|
.header(header::CONTENT_LENGTH, result.size)
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_DISPOSITION,
|
header::CONTENT_DISPOSITION,
|
||||||
format!("inline; filename=\"{}\"", result.filename),
|
format!("inline; filename=\"{}\"", result.filename),
|
||||||
)
|
)
|
||||||
.body(Body::from(result.data))
|
.body(Body::from_stream(result.stream))
|
||||||
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
|
.map_err(|e| AppError::from(domain::errors::DomainError::Internal(e.to_string())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/assets/{id}/tags",
|
||||||
|
request_body = TagAssetRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||||
|
responses(
|
||||||
|
(status = 201, description = "Tag applied", body = TagResponse),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn tag_asset(
|
pub async fn tag_asset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
@@ -152,57 +335,77 @@ pub async fn tag_asset(
|
|||||||
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
|
Ok((StatusCode::CREATED, Json(TagResponse::from_domain(&tag))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
delete, path = "/api/v1/assets/{id}",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Asset ID")),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Asset deleted"),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn delete_asset(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let cmd = DeleteAssetCommand {
|
||||||
|
asset_id: SystemId::from_uuid(asset_id),
|
||||||
|
deleted_by: claims.user_id,
|
||||||
|
};
|
||||||
|
state.catalog.delete_asset.execute(cmd).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/assets/{id}/derivatives/{profile}",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(
|
||||||
|
("id" = uuid::Uuid, Path, description = "Asset ID"),
|
||||||
|
("profile" = String, Path, description = "Derivative profile")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Derivative content", content_type = "application/octet-stream"),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
pub async fn serve_derivative(
|
pub async fn serve_derivative(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
|
Path((asset_id, profile)): Path<(uuid::Uuid, String)>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let profile = parse_derivative_profile(&profile)?;
|
let profile = parsers::derivative_profile(&profile)?;
|
||||||
let query = ReadDerivativeQuery {
|
let query = ReadDerivativeQuery {
|
||||||
asset_id: SystemId::from_uuid(asset_id),
|
asset_id: SystemId::from_uuid(asset_id),
|
||||||
profile,
|
profile,
|
||||||
|
caller_id: claims.user_id,
|
||||||
};
|
};
|
||||||
let result = state.catalog.read_derivative.execute(query).await?;
|
let result = state.catalog.read_derivative.execute(query).await?;
|
||||||
|
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
.header(header::CONTENT_TYPE, &result.mime_type)
|
.header(header::CONTENT_TYPE, &result.mime_type)
|
||||||
.header(header::CONTENT_LENGTH, result.data.len())
|
.header(header::CONTENT_LENGTH, result.size)
|
||||||
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
|
||||||
.body(Body::from(result.data))
|
.body(Body::from_stream(result.stream))
|
||||||
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
|
.map_err(|e| AppError::from(DomainError::Internal(e.to_string())))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_derivative_profile(s: &str) -> Result<domain::entities::DerivativeProfile, AppError> {
|
#[utoipa::path(
|
||||||
use domain::entities::DerivativeProfile;
|
post, path = "/api/v1/assets/register",
|
||||||
match s {
|
request_body = RegisterAssetRequest,
|
||||||
"thumbnail" | "thumbnail_square" => Ok(DerivativeProfile::ThumbnailSquare),
|
security(("bearer_token" = [])),
|
||||||
"thumbnail_large" => Ok(DerivativeProfile::ThumbnailLarge),
|
responses(
|
||||||
"web" | "web_optimized" => Ok(DerivativeProfile::WebOptimized),
|
(status = 201, description = "Asset registered", body = AssetResponse),
|
||||||
"video_sd" => Ok(DerivativeProfile::VideoSd),
|
(status = 401, description = "Unauthorized")
|
||||||
_ => Err(AppError::from(DomainError::Validation(format!(
|
)
|
||||||
"Unknown derivative profile: {s}"
|
)]
|
||||||
)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_asset_type(s: &str) -> Result<AssetType, AppError> {
|
|
||||||
match s {
|
|
||||||
"image" => Ok(AssetType::Image),
|
|
||||||
"video" => Ok(AssetType::Video),
|
|
||||||
"live_photo" => Ok(AssetType::LivePhoto),
|
|
||||||
_ => Err(AppError::from(DomainError::Validation(format!(
|
|
||||||
"Invalid asset type: {s}"
|
|
||||||
)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn register_asset(
|
pub async fn register_asset(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
claims: JwtClaims,
|
claims: JwtClaims,
|
||||||
Json(req): Json<RegisterAssetRequest>,
|
Json(req): Json<RegisterAssetRequest>,
|
||||||
) -> Result<(StatusCode, Json<AssetResponse>), AppError> {
|
) -> Result<(StatusCode, Json<AssetResponse>), AppError> {
|
||||||
let asset_type = parse_asset_type(&req.asset_type)?;
|
let asset_type = parsers::asset_type(&req.asset_type)?;
|
||||||
let cmd = RegisterAssetCommand {
|
let cmd = RegisterAssetCommand {
|
||||||
volume_id: SystemId::from_uuid(req.volume_id),
|
volume_id: SystemId::from_uuid(req.volume_id),
|
||||||
relative_path: req.relative_path,
|
relative_path: req.relative_path,
|
||||||
@@ -218,3 +421,99 @@ pub async fn register_asset(
|
|||||||
Json(AssetResponse::from_domain(&asset, &StructuredData::new())),
|
Json(AssetResponse::from_domain(&asset, &StructuredData::new())),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/assets/bulk-delete",
|
||||||
|
request_body = api_types::requests::BulkDeleteRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Assets deleted"),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn bulk_delete(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Json(req): Json<api_types::requests::BulkDeleteRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let mut deleted = 0u32;
|
||||||
|
for id in req.asset_ids {
|
||||||
|
let cmd = DeleteAssetCommand {
|
||||||
|
asset_id: SystemId::from_uuid(id),
|
||||||
|
deleted_by: claims.user_id,
|
||||||
|
};
|
||||||
|
state.catalog.delete_asset.execute(cmd).await?;
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "deleted": deleted })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/assets/bulk-tag",
|
||||||
|
request_body = api_types::requests::BulkTagRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Assets tagged"),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn bulk_tag(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Json(req): Json<api_types::requests::BulkTagRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let mut tagged = 0u32;
|
||||||
|
for id in req.asset_ids {
|
||||||
|
let cmd = application::organization::TagAssetCommand {
|
||||||
|
asset_id: SystemId::from_uuid(id),
|
||||||
|
tag_name: req.tag_name.clone(),
|
||||||
|
user_id: claims.user_id,
|
||||||
|
};
|
||||||
|
state.organization.tag_asset.execute(cmd).await?;
|
||||||
|
tagged += 1;
|
||||||
|
}
|
||||||
|
Ok(Json(serde_json::json!({ "tagged": tagged })))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_asset(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((asset_id,)): Path<(uuid::Uuid,)>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let cmd = RestoreAssetCommand {
|
||||||
|
asset_id: SystemId::from_uuid(asset_id),
|
||||||
|
user_id: claims.user_id,
|
||||||
|
};
|
||||||
|
state.catalog.restore_asset.execute(cmd).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct TrashParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_trash(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Query(params): Query<TrashParams>,
|
||||||
|
) -> Result<Json<TimelineResponse>, AppError> {
|
||||||
|
let limit = params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE);
|
||||||
|
let offset = params.offset.unwrap_or(0);
|
||||||
|
let query = ListTrashQuery {
|
||||||
|
owner_id: claims.user_id,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
};
|
||||||
|
let result = state.catalog.list_trash.execute(query).await?;
|
||||||
|
let items = result
|
||||||
|
.assets
|
||||||
|
.iter()
|
||||||
|
.map(|a| AssetResponse::from_domain(a, &StructuredData::new()))
|
||||||
|
.collect();
|
||||||
|
Ok(Json(TimelineResponse {
|
||||||
|
assets: items,
|
||||||
|
total: result.total,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ use crate::{
|
|||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
use api_types::{
|
use api_types::{
|
||||||
requests::{LoginRequest, RegisterRequest},
|
requests::{LoginRequest, RefreshTokenRequest, RegisterRequest},
|
||||||
responses::{AuthResponse, UserResponse},
|
responses::{AuthResponse, UserResponse},
|
||||||
};
|
};
|
||||||
use application::identity::{GetProfileQuery, LoginUserCommand, RegisterUserCommand};
|
use application::identity::{
|
||||||
|
GetProfileQuery, LoginUserCommand, RefreshTokenCommand, RegisterUserCommand,
|
||||||
|
generate_refresh_token,
|
||||||
|
};
|
||||||
use axum::{Json, extract::State, http::StatusCode};
|
use axum::{Json, extract::State, http::StatusCode};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -31,13 +34,16 @@ pub async fn register(
|
|||||||
let user = state.identity.register.execute(cmd).await?;
|
let user = state.identity.register.execute(cmd).await?;
|
||||||
let token = state
|
let token = state
|
||||||
.token_issuer
|
.token_issuer
|
||||||
.issue(&user.id, "user")
|
.issue(&user.id, &user.role)
|
||||||
.await
|
.await
|
||||||
.map_err(AppError::from)?;
|
.map_err(AppError::from)?;
|
||||||
|
let (refresh_token, _) =
|
||||||
|
generate_refresh_token(&state.identity.refresh_token_repo, &user.id).await?;
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::CREATED,
|
StatusCode::CREATED,
|
||||||
Json(AuthResponse {
|
Json(AuthResponse {
|
||||||
token,
|
token,
|
||||||
|
refresh_token,
|
||||||
user: UserResponse::from_domain(&user),
|
user: UserResponse::from_domain(&user),
|
||||||
}),
|
}),
|
||||||
))
|
))
|
||||||
@@ -59,9 +65,10 @@ pub async fn login(
|
|||||||
email: req.email,
|
email: req.email,
|
||||||
password: req.password,
|
password: req.password,
|
||||||
};
|
};
|
||||||
let (user, token) = state.identity.login.execute(cmd).await?;
|
let (user, token, refresh_token) = state.identity.login.execute(cmd).await?;
|
||||||
Ok(Json(AuthResponse {
|
Ok(Json(AuthResponse {
|
||||||
token,
|
token,
|
||||||
|
refresh_token,
|
||||||
user: UserResponse::from_domain(&user),
|
user: UserResponse::from_domain(&user),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -84,3 +91,48 @@ pub async fn me(
|
|||||||
let user = state.identity.get_profile.execute(query).await?;
|
let user = state.identity.get_profile.execute(query).await?;
|
||||||
Ok(Json(UserResponse::from_domain(&user)))
|
Ok(Json(UserResponse::from_domain(&user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/auth/refresh",
|
||||||
|
request_body = RefreshTokenRequest,
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Token refreshed", body = AuthResponse),
|
||||||
|
(status = 401, description = "Invalid refresh token")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn refresh(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ValidatedJson(req): ValidatedJson<RefreshTokenRequest>,
|
||||||
|
) -> Result<Json<AuthResponse>, AppError> {
|
||||||
|
let cmd = RefreshTokenCommand {
|
||||||
|
refresh_token: req.refresh_token,
|
||||||
|
};
|
||||||
|
let (access_token, refresh_token) = state.identity.refresh.execute(cmd).await?;
|
||||||
|
let (user_id, _) = state.token_issuer.verify(&access_token).await?;
|
||||||
|
let user = state
|
||||||
|
.identity
|
||||||
|
.get_profile
|
||||||
|
.execute(GetProfileQuery { user_id })
|
||||||
|
.await?;
|
||||||
|
Ok(Json(AuthResponse {
|
||||||
|
token: access_token,
|
||||||
|
refresh_token,
|
||||||
|
user: UserResponse::from_domain(&user),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/auth/logout",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
responses(
|
||||||
|
(status = 204, description = "Logged out"),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn logout(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
state.identity.logout.execute(&claims.user_id).await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|||||||
75
crates/presentation/src/handlers/duplicates.rs
Normal file
75
crates/presentation/src/handlers/duplicates.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::{
|
||||||
|
constants::{DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE},
|
||||||
|
errors::AppError,
|
||||||
|
extractors::JwtClaims,
|
||||||
|
state::AppState,
|
||||||
|
};
|
||||||
|
use api_types::{requests::ResolveDuplicateRequest, responses::DuplicateGroupResponse};
|
||||||
|
use application::catalog::{ListDuplicatesQuery, ResolveDuplicateCommand};
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct ListDuplicatesParams {
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
get, path = "/api/v1/duplicates",
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(
|
||||||
|
("limit" = Option<u32>, Query, description = "Page size"),
|
||||||
|
("offset" = Option<u32>, Query, description = "Page offset")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Duplicate groups", body = Vec<DuplicateGroupResponse>),
|
||||||
|
(status = 401, description = "Unauthorized")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn list_duplicates(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Query(params): Query<ListDuplicatesParams>,
|
||||||
|
) -> Result<Json<Vec<DuplicateGroupResponse>>, AppError> {
|
||||||
|
super::require_admin(&claims)?;
|
||||||
|
let query = ListDuplicatesQuery {
|
||||||
|
limit: params.limit.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE),
|
||||||
|
offset: params.offset.unwrap_or(0),
|
||||||
|
};
|
||||||
|
let groups = state.catalog.list_duplicates.execute(query).await?;
|
||||||
|
let resp = groups
|
||||||
|
.iter()
|
||||||
|
.map(DuplicateGroupResponse::from_domain)
|
||||||
|
.collect();
|
||||||
|
Ok(Json(resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
post, path = "/api/v1/duplicates/{id}/resolve",
|
||||||
|
request_body = ResolveDuplicateRequest,
|
||||||
|
security(("bearer_token" = [])),
|
||||||
|
params(("id" = uuid::Uuid, Path, description = "Duplicate group ID")),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Duplicate resolved"),
|
||||||
|
(status = 404, description = "Not found")
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
pub async fn resolve_duplicate(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
claims: JwtClaims,
|
||||||
|
Path((group_id,)): Path<(uuid::Uuid,)>,
|
||||||
|
Json(req): Json<ResolveDuplicateRequest>,
|
||||||
|
) -> Result<StatusCode, AppError> {
|
||||||
|
let cmd = ResolveDuplicateCommand {
|
||||||
|
group_id: SystemId::from_uuid(group_id),
|
||||||
|
keep_asset_id: SystemId::from_uuid(req.keep_asset_id),
|
||||||
|
resolved_by: claims.user_id,
|
||||||
|
};
|
||||||
|
state.catalog.resolve_duplicate.execute(cmd).await?;
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user