Compare commits
32 Commits
a6b86c23d8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c251a5c41f | |||
| 0077caa743 | |||
| 957737ac9b | |||
| 49f77a78b9 | |||
| 6140ecd3ba | |||
| 7b5bb66b37 | |||
| bcaf49cc81 | |||
| d879fd6437 | |||
| 168f2a6a27 | |||
| c6f82090d2 | |||
| 84fb410316 | |||
| 95916cedde | |||
| ef64e86439 | |||
| f85c0cb246 | |||
| d379f3d3c8 | |||
| e11a1a828b | |||
| 35d5baf7be | |||
| 45669ec848 | |||
| d1c7243f5b | |||
| b5cda3afeb | |||
| 0b2237860e | |||
| aa09aec66b | |||
| d022cb9068 | |||
| 5a4eb1e4f8 | |||
| c16c9d4581 | |||
| 2fe0a4c245 | |||
| 838ed9a3f8 | |||
| 0e9911ebfc | |||
| dacfc3d453 | |||
| 6c88ac344c | |||
| aff772f6d7 | |||
| e082387f6e |
33
.env.example
33
.env.example
@@ -7,36 +7,49 @@
|
|||||||
# Server
|
# Server
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=3000
|
PORT=8000
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Database
|
# Database
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos
|
DATABASE_URL=postgres://kphotos:kphotos@localhost:5432/kphotos
|
||||||
|
|
||||||
DB_MAX_CONNECTIONS=5
|
|
||||||
DB_MIN_CONNECTIONS=1
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# JWT
|
# JWT
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
JWT_SECRET=change-me-in-production-at-least-32-characters
|
JWT_SECRET=change-me-in-production-at-least-32-characters
|
||||||
|
|
||||||
# Token lifetime in hours (default: 24)
|
# ============================================================================
|
||||||
JWT_EXPIRY_HOURS=24
|
# NATS
|
||||||
|
# ============================================================================
|
||||||
|
NATS_URL=nats://localhost:4222
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# CORS
|
# CORS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
|
CORS_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:5173
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Storage
|
# Storage
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
STORAGE_BACKEND=local
|
|
||||||
STORAGE_PATH=./data/media
|
STORAGE_PATH=./data/media
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Production Mode
|
# Uploads (default 256 MiB)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
PRODUCTION=false
|
# MAX_UPLOAD_BYTES=268435456
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Worker concurrency (default: number of CPU cores)
|
||||||
|
# ============================================================================
|
||||||
|
# WORKER_CONCURRENCY=8
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Trash (default 30 days before permanent purge)
|
||||||
|
# ============================================================================
|
||||||
|
# TRASH_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Logging
|
||||||
|
# ============================================================================
|
||||||
|
RUST_LOG=info
|
||||||
|
|||||||
1387
Cargo.lock
generated
1387
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
19
Cargo.toml
@@ -7,6 +7,12 @@ members = [
|
|||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/auth",
|
"crates/adapters/auth",
|
||||||
"crates/adapters/storage",
|
"crates/adapters/storage",
|
||||||
|
"crates/adapters/event-payload",
|
||||||
|
"crates/adapters/event-transport",
|
||||||
|
"crates/adapters/nats",
|
||||||
|
"crates/adapters/exif",
|
||||||
|
"crates/adapters/thumbnail",
|
||||||
|
"crates/adapters/sidecar",
|
||||||
"crates/presentation",
|
"crates/presentation",
|
||||||
"crates/bootstrap",
|
"crates/bootstrap",
|
||||||
"crates/worker",
|
"crates/worker",
|
||||||
@@ -14,7 +20,7 @@ members = [
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync"] }
|
tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net", "time", "sync", "signal"] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
bytes = "1.0"
|
bytes = "1.0"
|
||||||
@@ -22,9 +28,11 @@ anyhow = "1.0"
|
|||||||
thiserror = "2.0"
|
thiserror = "2.0"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
email_address = "0.2"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
@@ -40,4 +48,13 @@ application = { path = "crates/application" }
|
|||||||
api-types = { path = "crates/api-types" }
|
api-types = { path = "crates/api-types" }
|
||||||
adapters-auth = { path = "crates/adapters/auth" }
|
adapters-auth = { path = "crates/adapters/auth" }
|
||||||
adapters-storage = { path = "crates/adapters/storage" }
|
adapters-storage = { path = "crates/adapters/storage" }
|
||||||
|
adapters-event-payload = { path = "crates/adapters/event-payload" }
|
||||||
|
adapters-event-transport = { path = "crates/adapters/event-transport" }
|
||||||
|
adapters-nats = { path = "crates/adapters/nats" }
|
||||||
|
adapters-exif = { path = "crates/adapters/exif" }
|
||||||
|
adapters-thumbnail = { path = "crates/adapters/thumbnail" }
|
||||||
|
adapters-sidecar = { path = "crates/adapters/sidecar" }
|
||||||
|
adapters-postgres = { path = "crates/adapters/postgres" }
|
||||||
|
async-nats = "0.48"
|
||||||
|
async-stream = "0.3"
|
||||||
presentation = { path = "crates/presentation" }
|
presentation = { path = "crates/presentation" }
|
||||||
|
|||||||
58
Dockerfile
Normal file
58
Dockerfile
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ----- build -----
|
||||||
|
FROM rust:slim-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Cache dependency compilation separately from source
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
|
||||||
|
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
|
||||||
|
COPY crates/adapters/event-transport/Cargo.toml crates/adapters/event-transport/Cargo.toml
|
||||||
|
COPY crates/adapters/exif/Cargo.toml crates/adapters/exif/Cargo.toml
|
||||||
|
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
|
||||||
|
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||||
|
COPY crates/adapters/sidecar/Cargo.toml crates/adapters/sidecar/Cargo.toml
|
||||||
|
COPY crates/adapters/storage/Cargo.toml crates/adapters/storage/Cargo.toml
|
||||||
|
COPY crates/adapters/thumbnail/Cargo.toml crates/adapters/thumbnail/Cargo.toml
|
||||||
|
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||||
|
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||||
|
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
|
||||||
|
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||||
|
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||||
|
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||||
|
|
||||||
|
# Stub every crate so cargo can resolve and fetch deps without real source
|
||||||
|
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||||
|
xargs -I{} sh -c 'mkdir -p {}/src && echo "fn main(){}" > {}/src/main.rs && echo "" > {}/src/lib.rs'
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config \
|
||||||
|
libssl-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN cargo fetch
|
||||||
|
|
||||||
|
# Now copy real source and build
|
||||||
|
COPY crates ./crates
|
||||||
|
|
||||||
|
RUN cargo build --release -p bootstrap -p worker --features storage/s3
|
||||||
|
|
||||||
|
# ----- runtime -----
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
libssl3 \
|
||||||
|
wget \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/k_photos ./k_photos
|
||||||
|
COPY --from=builder /build/target/release/k_photos-worker ./k_photos-worker
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENV RUST_LOG=info
|
||||||
|
|
||||||
|
CMD ["./k_photos"]
|
||||||
121
README.md
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,25 +51,3 @@ impl TokenIssuer for JwtTokenIssuer {
|
|||||||
Ok((SystemId::from_uuid(uuid), data.claims.role))
|
Ok((SystemId::from_uuid(uuid), data.claims.role))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn issue_and_verify_roundtrip() {
|
|
||||||
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
|
||||||
let user_id = SystemId::new();
|
|
||||||
let token = issuer.issue(&user_id, "user").await.unwrap();
|
|
||||||
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
|
|
||||||
assert_eq!(verified_id, user_id);
|
|
||||||
assert_eq!(verified_role, "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_invalid_token() {
|
|
||||||
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
|
||||||
let result = issuer.verify("not.a.valid.jwt").await;
|
|
||||||
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,16 +23,3 @@ impl PasswordHasher for BcryptPasswordHasher {
|
|||||||
.map_err(|e| DomainError::Internal(e.to_string()))
|
.map_err(|e| DomainError::Internal(e.to_string()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn hash_and_verify_roundtrip() {
|
|
||||||
let h = BcryptPasswordHasher;
|
|
||||||
let hash = h.hash("mysecretpassword").await.unwrap();
|
|
||||||
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
|
|
||||||
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
2
crates/adapters/auth/tests/auth_tests.rs
Normal file
2
crates/adapters/auth/tests/auth_tests.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
mod jwt;
|
||||||
|
mod password;
|
||||||
21
crates/adapters/auth/tests/jwt.rs
Normal file
21
crates/adapters/auth/tests/jwt.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use adapters_auth::JwtTokenIssuer;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::TokenIssuer;
|
||||||
|
use domain::value_objects::SystemId;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn issue_and_verify_roundtrip() {
|
||||||
|
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
||||||
|
let user_id = SystemId::new();
|
||||||
|
let token = issuer.issue(&user_id, "user").await.unwrap();
|
||||||
|
let (verified_id, verified_role) = issuer.verify(&token).await.unwrap();
|
||||||
|
assert_eq!(verified_id, user_id);
|
||||||
|
assert_eq!(verified_role, "user");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_invalid_token() {
|
||||||
|
let issuer = JwtTokenIssuer::new("test-secret-key-long-enough-32chars!!");
|
||||||
|
let result = issuer.verify("not.a.valid.jwt").await;
|
||||||
|
assert!(matches!(result, Err(DomainError::Unauthorized(_))));
|
||||||
|
}
|
||||||
10
crates/adapters/auth/tests/password.rs
Normal file
10
crates/adapters/auth/tests/password.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use adapters_auth::BcryptPasswordHasher;
|
||||||
|
use domain::ports::PasswordHasher;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hash_and_verify_roundtrip() {
|
||||||
|
let h = BcryptPasswordHasher;
|
||||||
|
let hash = h.hash("mysecretpassword").await.unwrap();
|
||||||
|
assert!(h.verify("mysecretpassword", &hash).await.unwrap());
|
||||||
|
assert!(!h.verify("wrongpassword", &hash).await.unwrap());
|
||||||
|
}
|
||||||
11
crates/adapters/event-payload/Cargo.toml
Normal file
11
crates/adapters/event-payload/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-event-payload"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
382
crates/adapters/event-payload/src/lib.rs
Normal file
382
crates/adapters/event-payload/src/lib.rs
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
use domain::{errors::DomainError, events::DomainEvent, value_objects::SystemId};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", content = "data")]
|
||||||
|
pub enum EventPayload {
|
||||||
|
AssetIngested {
|
||||||
|
asset_id: String,
|
||||||
|
owner_user_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
MetadataUpdated {
|
||||||
|
asset_id: String,
|
||||||
|
updated_by: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
AssetDeleted {
|
||||||
|
asset_id: String,
|
||||||
|
deleted_by: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
ShareCreated {
|
||||||
|
scope_id: String,
|
||||||
|
shareable_id: String,
|
||||||
|
created_by: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
ShareRevoked {
|
||||||
|
scope_id: String,
|
||||||
|
revoked_by: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
SidecarSyncRequested {
|
||||||
|
asset_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
DerivativeGenerated {
|
||||||
|
asset_id: String,
|
||||||
|
derivative_id: String,
|
||||||
|
profile: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
JobEnqueued {
|
||||||
|
job_id: String,
|
||||||
|
job_type: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
JobCompleted {
|
||||||
|
job_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
JobFailed {
|
||||||
|
job_id: String,
|
||||||
|
error: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
UserCreated {
|
||||||
|
user_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
UserDeleted {
|
||||||
|
user_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
AlbumCreated {
|
||||||
|
album_id: String,
|
||||||
|
creator_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
TagCreated {
|
||||||
|
tag_id: String,
|
||||||
|
asset_id: String,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
DuplicateDetected {
|
||||||
|
group_id: String,
|
||||||
|
asset_ids: Vec<String>,
|
||||||
|
timestamp: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventPayload {
|
||||||
|
pub fn subject(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::AssetIngested { .. } => "assets.ingested",
|
||||||
|
Self::MetadataUpdated { .. } => "metadata.updated",
|
||||||
|
Self::AssetDeleted { .. } => "assets.deleted",
|
||||||
|
Self::ShareCreated { .. } => "shares.created",
|
||||||
|
Self::ShareRevoked { .. } => "shares.revoked",
|
||||||
|
Self::SidecarSyncRequested { .. } => "sidecars.sync_requested",
|
||||||
|
Self::DerivativeGenerated { .. } => "derivatives.generated",
|
||||||
|
Self::JobEnqueued { .. } => "jobs.enqueued",
|
||||||
|
Self::JobCompleted { .. } => "jobs.completed",
|
||||||
|
Self::JobFailed { .. } => "jobs.failed",
|
||||||
|
Self::UserCreated { .. } => "users.created",
|
||||||
|
Self::UserDeleted { .. } => "users.deleted",
|
||||||
|
Self::AlbumCreated { .. } => "albums.created",
|
||||||
|
Self::TagCreated { .. } => "tags.created",
|
||||||
|
Self::DuplicateDetected { .. } => "duplicates.detected",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&DomainEvent> for EventPayload {
|
||||||
|
fn from(e: &DomainEvent) -> Self {
|
||||||
|
match e {
|
||||||
|
DomainEvent::AssetIngested {
|
||||||
|
asset_id,
|
||||||
|
owner_user_id,
|
||||||
|
timestamp,
|
||||||
|
} => Self::AssetIngested {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
owner_user_id: owner_user_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::MetadataUpdated {
|
||||||
|
asset_id,
|
||||||
|
updated_by,
|
||||||
|
timestamp,
|
||||||
|
} => Self::MetadataUpdated {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
updated_by: updated_by.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AssetDeleted {
|
||||||
|
asset_id,
|
||||||
|
deleted_by,
|
||||||
|
timestamp,
|
||||||
|
} => Self::AssetDeleted {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
deleted_by: deleted_by.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::ShareCreated {
|
||||||
|
scope_id,
|
||||||
|
shareable_id,
|
||||||
|
created_by,
|
||||||
|
timestamp,
|
||||||
|
} => Self::ShareCreated {
|
||||||
|
scope_id: scope_id.to_string(),
|
||||||
|
shareable_id: shareable_id.to_string(),
|
||||||
|
created_by: created_by.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::ShareRevoked {
|
||||||
|
scope_id,
|
||||||
|
revoked_by,
|
||||||
|
timestamp,
|
||||||
|
} => Self::ShareRevoked {
|
||||||
|
scope_id: scope_id.to_string(),
|
||||||
|
revoked_by: revoked_by.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::SidecarSyncRequested {
|
||||||
|
asset_id,
|
||||||
|
timestamp,
|
||||||
|
} => Self::SidecarSyncRequested {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::DerivativeGenerated {
|
||||||
|
asset_id,
|
||||||
|
derivative_id,
|
||||||
|
profile,
|
||||||
|
timestamp,
|
||||||
|
} => Self::DerivativeGenerated {
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
derivative_id: derivative_id.to_string(),
|
||||||
|
profile: profile.clone(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::JobEnqueued {
|
||||||
|
job_id,
|
||||||
|
job_type,
|
||||||
|
timestamp,
|
||||||
|
} => Self::JobEnqueued {
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
job_type: job_type.clone(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::JobCompleted { job_id, timestamp } => Self::JobCompleted {
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::JobFailed {
|
||||||
|
job_id,
|
||||||
|
error,
|
||||||
|
timestamp,
|
||||||
|
} => Self::JobFailed {
|
||||||
|
job_id: job_id.to_string(),
|
||||||
|
error: error.clone(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::UserCreated { user_id, timestamp } => Self::UserCreated {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::UserDeleted { user_id, timestamp } => Self::UserDeleted {
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::AlbumCreated {
|
||||||
|
album_id,
|
||||||
|
creator_id,
|
||||||
|
timestamp,
|
||||||
|
} => Self::AlbumCreated {
|
||||||
|
album_id: album_id.to_string(),
|
||||||
|
creator_id: creator_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::TagCreated {
|
||||||
|
tag_id,
|
||||||
|
asset_id,
|
||||||
|
timestamp,
|
||||||
|
} => Self::TagCreated {
|
||||||
|
tag_id: tag_id.to_string(),
|
||||||
|
asset_id: asset_id.to_string(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
DomainEvent::DuplicateDetected {
|
||||||
|
group_id,
|
||||||
|
asset_ids,
|
||||||
|
timestamp,
|
||||||
|
} => Self::DuplicateDetected {
|
||||||
|
group_id: group_id.to_string(),
|
||||||
|
asset_ids: asset_ids.iter().map(|id| id.to_string()).collect(),
|
||||||
|
timestamp: timestamp.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(s: &str, field: &str) -> Result<uuid::Uuid, DomainError> {
|
||||||
|
uuid::Uuid::parse_str(s)
|
||||||
|
.map_err(|_| DomainError::Internal(format!("invalid uuid for {field}: {s}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_timestamp(s: &str) -> Result<domain::value_objects::DateTimeStamp, DomainError> {
|
||||||
|
use chrono::DateTime;
|
||||||
|
let dt = DateTime::parse_from_rfc3339(s)
|
||||||
|
.map_err(|_| DomainError::Internal(format!("invalid timestamp: {s}")))?;
|
||||||
|
Ok(domain::value_objects::DateTimeStamp::from_datetime(
|
||||||
|
dt.with_timezone(&chrono::Utc),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<EventPayload> for DomainEvent {
|
||||||
|
type Error = DomainError;
|
||||||
|
|
||||||
|
fn try_from(p: EventPayload) -> Result<Self, DomainError> {
|
||||||
|
Ok(match p {
|
||||||
|
EventPayload::AssetIngested {
|
||||||
|
asset_id,
|
||||||
|
owner_user_id,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::AssetIngested {
|
||||||
|
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||||
|
owner_user_id: SystemId::from_uuid(parse_uuid(&owner_user_id, "owner_user_id")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::MetadataUpdated {
|
||||||
|
asset_id,
|
||||||
|
updated_by,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::MetadataUpdated {
|
||||||
|
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||||
|
updated_by: SystemId::from_uuid(parse_uuid(&updated_by, "updated_by")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::AssetDeleted {
|
||||||
|
asset_id,
|
||||||
|
deleted_by,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::AssetDeleted {
|
||||||
|
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||||
|
deleted_by: SystemId::from_uuid(parse_uuid(&deleted_by, "deleted_by")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::ShareCreated {
|
||||||
|
scope_id,
|
||||||
|
shareable_id,
|
||||||
|
created_by,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::ShareCreated {
|
||||||
|
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
|
||||||
|
shareable_id: SystemId::from_uuid(parse_uuid(&shareable_id, "shareable_id")?),
|
||||||
|
created_by: SystemId::from_uuid(parse_uuid(&created_by, "created_by")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::ShareRevoked {
|
||||||
|
scope_id,
|
||||||
|
revoked_by,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::ShareRevoked {
|
||||||
|
scope_id: SystemId::from_uuid(parse_uuid(&scope_id, "scope_id")?),
|
||||||
|
revoked_by: SystemId::from_uuid(parse_uuid(&revoked_by, "revoked_by")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::SidecarSyncRequested {
|
||||||
|
asset_id,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::SidecarSyncRequested {
|
||||||
|
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::DerivativeGenerated {
|
||||||
|
asset_id,
|
||||||
|
derivative_id,
|
||||||
|
profile,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::DerivativeGenerated {
|
||||||
|
asset_id: SystemId::from_uuid(parse_uuid(&asset_id, "asset_id")?),
|
||||||
|
derivative_id: SystemId::from_uuid(parse_uuid(&derivative_id, "derivative_id")?),
|
||||||
|
profile,
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::JobEnqueued {
|
||||||
|
job_id,
|
||||||
|
job_type,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::JobEnqueued {
|
||||||
|
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||||
|
job_type,
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::JobCompleted { job_id, timestamp } => DomainEvent::JobCompleted {
|
||||||
|
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||||
|
timestamp: parse_timestamp(×tamp)?,
|
||||||
|
},
|
||||||
|
EventPayload::JobFailed {
|
||||||
|
job_id,
|
||||||
|
error,
|
||||||
|
timestamp,
|
||||||
|
} => DomainEvent::JobFailed {
|
||||||
|
job_id: SystemId::from_uuid(parse_uuid(&job_id, "job_id")?),
|
||||||
|
error,
|
||||||
|
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)?,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
89
crates/adapters/event-payload/src/tests.rs
Normal file
89
crates/adapters/event-payload/src/tests.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use crate::EventPayload;
|
||||||
|
use domain::{events::DomainEvent, value_objects::SystemId};
|
||||||
|
|
||||||
|
fn make_timestamp() -> domain::value_objects::DateTimeStamp {
|
||||||
|
domain::value_objects::DateTimeStamp::now()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subject_mapping() {
|
||||||
|
let cases = vec![
|
||||||
|
(
|
||||||
|
DomainEvent::AssetIngested {
|
||||||
|
asset_id: SystemId::new(),
|
||||||
|
owner_user_id: SystemId::new(),
|
||||||
|
timestamp: make_timestamp(),
|
||||||
|
},
|
||||||
|
"assets.ingested",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
DomainEvent::JobEnqueued {
|
||||||
|
job_id: SystemId::new(),
|
||||||
|
job_type: "extract_metadata".into(),
|
||||||
|
timestamp: make_timestamp(),
|
||||||
|
},
|
||||||
|
"jobs.enqueued",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
DomainEvent::JobFailed {
|
||||||
|
job_id: SystemId::new(),
|
||||||
|
error: "boom".into(),
|
||||||
|
timestamp: make_timestamp(),
|
||||||
|
},
|
||||||
|
"jobs.failed",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (event, expected_subject) in cases {
|
||||||
|
let payload = EventPayload::from(&event);
|
||||||
|
assert_eq!(payload.subject(), expected_subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_asset_ingested() {
|
||||||
|
let id = SystemId::new();
|
||||||
|
let owner = SystemId::new();
|
||||||
|
let event = DomainEvent::AssetIngested {
|
||||||
|
asset_id: id,
|
||||||
|
owner_user_id: owner,
|
||||||
|
timestamp: make_timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = EventPayload::from(&event);
|
||||||
|
let json = serde_json::to_vec(&payload).unwrap();
|
||||||
|
let back: EventPayload = serde_json::from_slice(&json).unwrap();
|
||||||
|
let restored = DomainEvent::try_from(back).unwrap();
|
||||||
|
|
||||||
|
if let DomainEvent::AssetIngested {
|
||||||
|
asset_id,
|
||||||
|
owner_user_id,
|
||||||
|
..
|
||||||
|
} = restored
|
||||||
|
{
|
||||||
|
assert_eq!(asset_id, id);
|
||||||
|
assert_eq!(owner_user_id, owner);
|
||||||
|
} else {
|
||||||
|
panic!("wrong variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn roundtrip_job_failed() {
|
||||||
|
let jid = SystemId::new();
|
||||||
|
let event = DomainEvent::JobFailed {
|
||||||
|
job_id: jid,
|
||||||
|
error: "plugin crashed".into(),
|
||||||
|
timestamp: make_timestamp(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = EventPayload::from(&event);
|
||||||
|
let back = DomainEvent::try_from(payload).unwrap();
|
||||||
|
|
||||||
|
if let DomainEvent::JobFailed { job_id, error, .. } = back {
|
||||||
|
assert_eq!(job_id, jid);
|
||||||
|
assert_eq!(error, "plugin crashed");
|
||||||
|
} else {
|
||||||
|
panic!("wrong variant");
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/adapters/event-transport/Cargo.toml
Normal file
15
crates/adapters/event-transport/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-event-transport"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
adapters-event-payload = { workspace = true }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||||
26
crates/adapters/event-transport/src/composite.rs
Normal file
26
crates/adapters/event-transport/src/composite.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
ports::{EventPublisher, EventStore},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct CompositeEventPublisher {
|
||||||
|
primary: Arc<dyn EventPublisher>,
|
||||||
|
store: Arc<dyn EventStore>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompositeEventPublisher {
|
||||||
|
pub fn new(primary: Arc<dyn EventPublisher>, store: Arc<dyn EventStore>) -> Self {
|
||||||
|
Self { primary, store }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventPublisher for CompositeEventPublisher {
|
||||||
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
self.store.append(event).await?;
|
||||||
|
self.primary.publish(event).await
|
||||||
|
}
|
||||||
|
}
|
||||||
102
crates/adapters/event-transport/src/lib.rs
Normal file
102
crates/adapters/event-transport/src/lib.rs
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
pub mod composite;
|
||||||
|
pub use composite::CompositeEventPublisher;
|
||||||
|
|
||||||
|
use adapters_event_payload::EventPayload;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::{DomainEvent, EventEnvelope},
|
||||||
|
ports::{EventConsumer, EventPublisher},
|
||||||
|
};
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Transport: Send + Sync {
|
||||||
|
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventPublisherAdapter<T: Transport> {
|
||||||
|
transport: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Transport> EventPublisherAdapter<T> {
|
||||||
|
pub fn new(transport: T) -> Self {
|
||||||
|
Self { transport }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<T: Transport> EventPublisher for EventPublisherAdapter<T> {
|
||||||
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
let payload = EventPayload::from(event);
|
||||||
|
let subject = payload.subject();
|
||||||
|
let bytes =
|
||||||
|
serde_json::to_vec(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
tracing::debug!(subject, "publishing event");
|
||||||
|
self.transport.publish_bytes(subject, &bytes).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RawMessage {
|
||||||
|
pub subject: String,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub delivery_count: u64,
|
||||||
|
pub ack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
pub nack: Box<dyn Fn() + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait MessageSource: Send + Sync {
|
||||||
|
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct EventConsumerAdapter<S: MessageSource> {
|
||||||
|
source: S,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: MessageSource> EventConsumerAdapter<S> {
|
||||||
|
pub fn new(source: S) -> Self {
|
||||||
|
Self { source }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: MessageSource> EventConsumer for EventConsumerAdapter<S> {
|
||||||
|
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
|
||||||
|
use futures::StreamExt;
|
||||||
|
let stream = self.source.messages();
|
||||||
|
Box::pin(stream.filter_map(|result| async move {
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("transport error: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Ok(msg) => {
|
||||||
|
let payload = match serde_json::from_slice::<EventPayload>(&msg.payload) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("failed to deserialize event payload, acking: {e}");
|
||||||
|
(msg.ack)();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let event = match DomainEvent::try_from(payload) {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("unknown event type, acking: {e}");
|
||||||
|
(msg.ack)();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(Ok(EventEnvelope {
|
||||||
|
event,
|
||||||
|
delivery_count: msg.delivery_count,
|
||||||
|
ack: msg.ack,
|
||||||
|
nack: msg.nack,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
61
crates/adapters/event-transport/src/tests.rs
Normal file
61
crates/adapters/event-transport/src/tests.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use crate::{EventPublisherAdapter, Transport};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError, events::DomainEvent, ports::EventPublisher, value_objects::SystemId,
|
||||||
|
};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
struct RecordingTransport {
|
||||||
|
messages: Arc<Mutex<Vec<(String, Vec<u8>)>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for RecordingTransport {
|
||||||
|
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((subject.to_string(), bytes.to_vec()));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn adapter_publishes_with_correct_subject() {
|
||||||
|
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let adapter = EventPublisherAdapter::new(RecordingTransport {
|
||||||
|
messages: messages.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = DomainEvent::JobCompleted {
|
||||||
|
job_id: SystemId::new(),
|
||||||
|
timestamp: domain::value_objects::DateTimeStamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.publish(&event).await.unwrap();
|
||||||
|
|
||||||
|
let recorded = messages.lock().unwrap();
|
||||||
|
assert_eq!(recorded.len(), 1);
|
||||||
|
assert_eq!(recorded[0].0, "jobs.completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn published_bytes_are_valid_json() {
|
||||||
|
let messages = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let adapter = EventPublisherAdapter::new(RecordingTransport {
|
||||||
|
messages: messages.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let event = DomainEvent::AssetIngested {
|
||||||
|
asset_id: SystemId::new(),
|
||||||
|
owner_user_id: SystemId::new(),
|
||||||
|
timestamp: domain::value_objects::DateTimeStamp::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
adapter.publish(&event).await.unwrap();
|
||||||
|
|
||||||
|
let recorded = messages.lock().unwrap();
|
||||||
|
let payload: adapters_event_payload::EventPayload =
|
||||||
|
serde_json::from_slice(&recorded[0].1).expect("should be valid JSON");
|
||||||
|
assert_eq!(payload.subject(), "assets.ingested");
|
||||||
|
}
|
||||||
9
crates/adapters/exif/Cargo.toml
Normal file
9
crates/adapters/exif/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-exif"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
nom-exif = { version = "2.5", features = ["serde"] }
|
||||||
80
crates/adapters/exif/src/lib.rs
Normal file
80
crates/adapters/exif/src/lib.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use bytes::Bytes;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::MetadataExtractorPort,
|
||||||
|
value_objects::{MetadataValue, StructuredData},
|
||||||
|
};
|
||||||
|
use nom_exif::{ExifIter, MediaParser, MediaSource, TrackInfo};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
pub struct NomExifExtractor;
|
||||||
|
|
||||||
|
impl MetadataExtractorPort for NomExifExtractor {
|
||||||
|
fn extract(&self, bytes: &Bytes) -> Result<StructuredData, DomainError> {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return Ok(StructuredData::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ms = match MediaSource::seekable(Cursor::new(bytes.as_ref())) {
|
||||||
|
Ok(ms) => ms,
|
||||||
|
Err(_) => return Ok(StructuredData::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut parser = MediaParser::new();
|
||||||
|
let mut data = StructuredData::new();
|
||||||
|
|
||||||
|
if ms.has_exif() {
|
||||||
|
let iter: ExifIter = match parser.parse(ms) {
|
||||||
|
Ok(iter) => iter,
|
||||||
|
Err(_) => return Ok(data),
|
||||||
|
};
|
||||||
|
|
||||||
|
for mut entry in iter {
|
||||||
|
let tag_name = match entry.tag() {
|
||||||
|
Some(t) => t.to_string(),
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if tag_name.starts_with("Unknown(") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = match entry.take_result() {
|
||||||
|
Ok(v) => v.to_string(),
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_noisy_value(&value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.insert(tag_name, MetadataValue::String(value));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let track_info = match parser.parse::<_, _, TrackInfo>(ms) {
|
||||||
|
Ok(info) => info,
|
||||||
|
Err(_) => return Ok(data),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (key, val) in track_info {
|
||||||
|
data.insert(
|
||||||
|
format!("track:{}", key),
|
||||||
|
MetadataValue::String(val.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_noisy_value(v: &str) -> bool {
|
||||||
|
v.starts_with("U16Array")
|
||||||
|
|| v.starts_with("U32Array")
|
||||||
|
|| v.starts_with("U8Array")
|
||||||
|
|| v.starts_with("URationalArray")
|
||||||
|
|| v.starts_with("Undefined")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
19
crates/adapters/exif/src/tests.rs
Normal file
19
crates/adapters/exif/src/tests.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use crate::NomExifExtractor;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use domain::ports::MetadataExtractorPort;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_bytes_returns_empty_data() {
|
||||||
|
let extractor = NomExifExtractor;
|
||||||
|
let result = extractor.extract(&Bytes::new());
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn garbage_bytes_returns_empty_data() {
|
||||||
|
let extractor = NomExifExtractor;
|
||||||
|
let result = extractor.extract(&Bytes::from_static(b"not a real image file"));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert!(result.unwrap().is_empty());
|
||||||
|
}
|
||||||
13
crates/adapters/nats/Cargo.toml
Normal file
13
crates/adapters/nats/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-nats"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
adapters-event-transport = { workspace = true }
|
||||||
|
async-nats = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
futures = { workspace = true }
|
||||||
|
tokio = { workspace = true }
|
||||||
|
tracing = { workspace = true }
|
||||||
212
crates/adapters/nats/src/lib.rs
Normal file
212
crates/adapters/nats/src/lib.rs
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
use adapters_event_transport::{MessageSource, RawMessage, Transport};
|
||||||
|
use async_nats::jetstream::{self, AckKind, stream::Config as StreamConfig};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use futures::stream::BoxStream;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const STREAM_NAME: &str = "KPHOTOS_EVENTS";
|
||||||
|
const STREAM_SUBJECT: &str = "kphotos-events.>";
|
||||||
|
const CONSUMER_NAME: &str = "worker";
|
||||||
|
const MAX_MESSAGES: i64 = 100_000;
|
||||||
|
|
||||||
|
pub const CONSUMER_MAX_DELIVER: i64 = 5;
|
||||||
|
const CONSUMER_ACK_WAIT_SECS: u64 = 30;
|
||||||
|
const ACK_TASK_TIMEOUT_SECS: u64 = 5;
|
||||||
|
|
||||||
|
fn stream_config() -> StreamConfig {
|
||||||
|
StreamConfig {
|
||||||
|
name: STREAM_NAME.to_string(),
|
||||||
|
subjects: vec![STREAM_SUBJECT.to_string()],
|
||||||
|
max_messages: MAX_MESSAGES,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_stream(client: &async_nats::Client) -> Result<(), DomainError> {
|
||||||
|
let js = jetstream::new(client.clone());
|
||||||
|
|
||||||
|
if js.update_stream(stream_config()).await.is_ok() {
|
||||||
|
tracing::info!(subject = STREAM_SUBJECT, "JetStream stream updated");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::warn!(
|
||||||
|
"JetStream stream update failed (incompatible config), deleting '{STREAM_NAME}' and recreating"
|
||||||
|
);
|
||||||
|
let _ = js.delete_stream(STREAM_NAME).await;
|
||||||
|
|
||||||
|
js.create_stream(stream_config())
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| DomainError::Internal(format!("JetStream stream create failed: {e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NatsTransport {
|
||||||
|
jetstream: jetstream::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsTransport {
|
||||||
|
pub fn new(client: async_nats::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
jetstream: jetstream::new(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Transport for NatsTransport {
|
||||||
|
async fn publish_bytes(&self, subject: &str, bytes: &[u8]) -> Result<(), DomainError> {
|
||||||
|
let full_subject = format!("kphotos-events.{subject}");
|
||||||
|
self.jetstream
|
||||||
|
.publish(full_subject, bytes.to_vec().into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct NatsMessageSource {
|
||||||
|
jetstream: jetstream::Context,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NatsMessageSource {
|
||||||
|
pub fn new(client: async_nats::Client) -> Self {
|
||||||
|
Self {
|
||||||
|
jetstream: jetstream::new(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageSource for NatsMessageSource {
|
||||||
|
fn messages(&self) -> BoxStream<'_, Result<RawMessage, DomainError>> {
|
||||||
|
use futures::stream;
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
|
let js = self.jetstream.clone();
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<RawMessage, DomainError>>(128);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let stream = match js.get_stream(STREAM_NAME).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(info) = stream.consumer_info(CONSUMER_NAME).await
|
||||||
|
&& info.config.deliver_subject.is_some()
|
||||||
|
{
|
||||||
|
tracing::info!("deleting old push consumer '{CONSUMER_NAME}', replacing with pull");
|
||||||
|
let _ = stream.delete_consumer(CONSUMER_NAME).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let consumer = match stream
|
||||||
|
.get_or_create_consumer(
|
||||||
|
CONSUMER_NAME,
|
||||||
|
jetstream::consumer::pull::Config {
|
||||||
|
durable_name: Some(CONSUMER_NAME.to_string()),
|
||||||
|
deliver_policy: jetstream::consumer::DeliverPolicy::New,
|
||||||
|
ack_policy: jetstream::consumer::AckPolicy::Explicit,
|
||||||
|
ack_wait: std::time::Duration::from_secs(CONSUMER_ACK_WAIT_SECS),
|
||||||
|
max_deliver: CONSUMER_MAX_DELIVER,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("NATS pull consumer ready");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let mut messages = match consumer.messages().await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("NATS messages() failed: {e}");
|
||||||
|
let _ = tx.send(Err(DomainError::Internal(e.to_string()))).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
while let Some(result) = messages.next().await {
|
||||||
|
let msg = match result {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("NATS message error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let subject = msg.subject.to_string();
|
||||||
|
let payload = msg.payload.to_vec();
|
||||||
|
let delivery_count = msg
|
||||||
|
.info()
|
||||||
|
.map(|info| info.delivered.max(0) as u64)
|
||||||
|
.unwrap_or(1);
|
||||||
|
let msg = Arc::new(msg);
|
||||||
|
let msg_nack = Arc::clone(&msg);
|
||||||
|
|
||||||
|
let raw = RawMessage {
|
||||||
|
subject,
|
||||||
|
payload,
|
||||||
|
delivery_count,
|
||||||
|
ack: Box::new(move || {
|
||||||
|
let m = Arc::clone(&msg);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
|
||||||
|
m.ack(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => tracing::warn!("NATS ack failed: {e}"),
|
||||||
|
Err(_) => tracing::warn!(
|
||||||
|
"NATS ack timed out after {ACK_TASK_TIMEOUT_SECS}s"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
nack: Box::new(move || {
|
||||||
|
let m = Arc::clone(&msg_nack);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(ACK_TASK_TIMEOUT_SECS),
|
||||||
|
m.ack_with(AckKind::Nak(None)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(Ok(())) => {}
|
||||||
|
Ok(Err(e)) => tracing::warn!("NATS nack failed: {e}"),
|
||||||
|
Err(_) => tracing::warn!(
|
||||||
|
"NATS nack timed out after {ACK_TASK_TIMEOUT_SECS}s"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
if tx.send(Ok(raw)).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let rx = Arc::new(TokioMutex::new(rx));
|
||||||
|
Box::pin(stream::unfold(rx, |rx| async move {
|
||||||
|
let item = rx.lock().await.recv().await?;
|
||||||
|
Some((item, rx))
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
adapters-event-payload = { workspace = true }
|
||||||
sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] }
|
sqlx = { workspace = true, features = ["postgres", "runtime-tokio", "migrate", "uuid", "chrono", "json"] }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
10
crates/adapters/postgres/migrations/010_event_log.sql
Normal file
10
crates/adapters/postgres/migrations/010_event_log.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS event_log (
|
||||||
|
event_id BIGSERIAL PRIMARY KEY,
|
||||||
|
aggregate_id UUID NOT NULL,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_event_log_aggregate ON event_log (aggregate_id);
|
||||||
|
CREATE INDEX idx_event_log_type ON event_log (event_type);
|
||||||
|
CREATE INDEX idx_event_log_occurred ON event_log (occurred_at);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Default plugins matching worker's InMemoryPluginRegistry
|
||||||
|
INSERT INTO plugins (plugin_id, name, plugin_type, is_enabled, configuration)
|
||||||
|
VALUES
|
||||||
|
('a0000000-0000-4000-8000-000000000001', 'metadata_extractor', 'media_processor', true, '{}'),
|
||||||
|
('a0000000-0000-4000-8000-000000000002', 'sidecar_sync', 'sidecar_writer', true, '{}'),
|
||||||
|
('a0000000-0000-4000-8000-000000000003', 'no_op', 'scheduled_task', true, '{}'),
|
||||||
|
('a0000000-0000-4000-8000-000000000004', 'thumbnail_generator', 'media_processor', true, '{}'),
|
||||||
|
('a0000000-0000-4000-8000-000000000005', 'directory_scanner', 'media_processor', true, '{}')
|
||||||
|
ON CONFLICT (plugin_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Pipeline: extract_metadata → metadata_extractor, then thumbnail_generator
|
||||||
|
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||||
|
VALUES (
|
||||||
|
'b0000000-0000-4000-8000-000000000001',
|
||||||
|
'extract_metadata',
|
||||||
|
'[{"plugin_id": "a0000000-0000-4000-8000-000000000001", "step_order": 0, "configuration": {}},
|
||||||
|
{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 1, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
|
||||||
|
)
|
||||||
|
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Pipeline: generate_derivative (standalone, configurable per-step)
|
||||||
|
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||||
|
VALUES (
|
||||||
|
'b0000000-0000-4000-8000-000000000003',
|
||||||
|
'generate_derivative',
|
||||||
|
'[{"plugin_id": "a0000000-0000-4000-8000-000000000004", "step_order": 0, "configuration": {"width": "300", "height": "300", "format": "webp", "profile": "ThumbnailSquare"}}]'
|
||||||
|
)
|
||||||
|
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Pipeline: scan_directory → directory_scanner
|
||||||
|
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||||
|
VALUES (
|
||||||
|
'b0000000-0000-4000-8000-000000000004',
|
||||||
|
'scan_directory',
|
||||||
|
'[{"plugin_id": "a0000000-0000-4000-8000-000000000005", "step_order": 0, "configuration": {}}]'
|
||||||
|
)
|
||||||
|
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Pipeline: sync_sidecar → sidecar_sync
|
||||||
|
INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||||
|
VALUES (
|
||||||
|
'b0000000-0000-4000-8000-000000000002',
|
||||||
|
'sync_sidecar',
|
||||||
|
'[{"plugin_id": "a0000000-0000-4000-8000-000000000002", "step_order": 0, "configuration": {}}]'
|
||||||
|
)
|
||||||
|
ON CONFLICT (pipeline_id) DO NOTHING;
|
||||||
14
crates/adapters/postgres/migrations/012_derivatives.sql
Normal file
14
crates/adapters/postgres/migrations/012_derivatives.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE derivatives (
|
||||||
|
derivative_id UUID PRIMARY KEY,
|
||||||
|
parent_asset_id UUID NOT NULL REFERENCES assets(asset_id),
|
||||||
|
profile_type TEXT NOT NULL,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
mime_type TEXT NOT NULL DEFAULT '',
|
||||||
|
file_size BIGINT NOT NULL DEFAULT 0,
|
||||||
|
width INTEGER NOT NULL DEFAULT 0,
|
||||||
|
height INTEGER NOT NULL DEFAULT 0,
|
||||||
|
generation_status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_derivatives_parent ON derivatives(parent_asset_id);
|
||||||
|
CREATE INDEX idx_derivatives_parent_profile ON derivatives(parent_asset_id, profile_type);
|
||||||
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;
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{Album, AlbumEntry},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::AlbumRepository,
|
|
||||||
value_objects::{DateTimeStamp, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct AlbumRow {
|
|
||||||
album_id: Uuid,
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
creator_user_id: Uuid,
|
|
||||||
cover_asset_id: Option<Uuid>,
|
|
||||||
start_date: Option<DateTime<Utc>>,
|
|
||||||
end_date: Option<DateTime<Utc>>,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct AlbumEntryRow {
|
|
||||||
album_id: Uuid,
|
|
||||||
asset_id: Uuid,
|
|
||||||
sort_order: i32,
|
|
||||||
added_at: DateTime<Utc>,
|
|
||||||
added_by_user_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AlbumEntryRow> for AlbumEntry {
|
|
||||||
fn from(r: AlbumEntryRow) -> Self {
|
|
||||||
Self {
|
|
||||||
asset_id: SystemId::from_uuid(r.asset_id),
|
|
||||||
sort_order: r.sort_order as u32,
|
|
||||||
added_at: DateTimeStamp::from_datetime(r.added_at),
|
|
||||||
added_by_user_id: SystemId::from_uuid(r.added_by_user_id),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn album_from_row(r: AlbumRow, entries: Vec<AlbumEntry>) -> Album {
|
|
||||||
Album {
|
|
||||||
album_id: SystemId::from_uuid(r.album_id),
|
|
||||||
title: r.title,
|
|
||||||
description: r.description,
|
|
||||||
creator_user_id: SystemId::from_uuid(r.creator_user_id),
|
|
||||||
cover_asset_id: r.cover_asset_id.map(SystemId::from_uuid),
|
|
||||||
start_date: r.start_date.map(DateTimeStamp::from_datetime),
|
|
||||||
end_date: r.end_date.map(DateTimeStamp::from_datetime),
|
|
||||||
entries,
|
|
||||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresAlbumRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresAlbumRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn load_entries(&self, album_id: &Uuid) -> Result<Vec<AlbumEntry>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, AlbumEntryRow>(
|
|
||||||
"SELECT album_id, asset_id, sort_order, added_at, added_by_user_id
|
|
||||||
FROM album_entries WHERE album_id = $1
|
|
||||||
ORDER BY sort_order ASC",
|
|
||||||
)
|
|
||||||
.bind(album_id)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl AlbumRepository for PostgresAlbumRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Album>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, AlbumRow>(
|
|
||||||
"SELECT album_id, title, description, creator_user_id, cover_asset_id,
|
|
||||||
start_date, end_date, created_at
|
|
||||||
FROM albums WHERE album_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let Some(r) = row else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let entries = self.load_entries(&r.album_id).await?;
|
|
||||||
Ok(Some(album_from_row(r, entries)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_creator(&self, creator_id: &SystemId) -> Result<Vec<Album>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, AlbumRow>(
|
|
||||||
"SELECT album_id, title, description, creator_user_id, cover_asset_id,
|
|
||||||
start_date, end_date, created_at
|
|
||||||
FROM albums WHERE creator_user_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*creator_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut albums = Vec::with_capacity(rows.len());
|
|
||||||
for r in rows {
|
|
||||||
let entries = self.load_entries(&r.album_id).await?;
|
|
||||||
albums.push(album_from_row(r, entries));
|
|
||||||
}
|
|
||||||
Ok(albums)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, album: &Album) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO albums (album_id, title, description, creator_user_id, cover_asset_id,
|
|
||||||
start_date, end_date, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
||||||
ON CONFLICT (album_id) DO UPDATE SET
|
|
||||||
title = EXCLUDED.title,
|
|
||||||
description = EXCLUDED.description,
|
|
||||||
cover_asset_id = EXCLUDED.cover_asset_id,
|
|
||||||
start_date = EXCLUDED.start_date,
|
|
||||||
end_date = EXCLUDED.end_date",
|
|
||||||
)
|
|
||||||
.bind(*album.album_id.as_uuid())
|
|
||||||
.bind(&album.title)
|
|
||||||
.bind(&album.description)
|
|
||||||
.bind(*album.creator_user_id.as_uuid())
|
|
||||||
.bind(album.cover_asset_id.as_ref().map(|id| *id.as_uuid()))
|
|
||||||
.bind(album.start_date.as_ref().map(|d| d.as_datetime()).copied())
|
|
||||||
.bind(album.end_date.as_ref().map(|d| d.as_datetime()).copied())
|
|
||||||
.bind(album.created_at.as_datetime())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
// Sync entries: delete all then re-insert
|
|
||||||
sqlx::query("DELETE FROM album_entries WHERE album_id = $1")
|
|
||||||
.bind(*album.album_id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
for entry in &album.entries {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO album_entries (album_id, asset_id, sort_order, added_at, added_by_user_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)",
|
|
||||||
)
|
|
||||||
.bind(*album.album_id.as_uuid())
|
|
||||||
.bind(*entry.asset_id.as_uuid())
|
|
||||||
.bind(entry.sort_order as i32)
|
|
||||||
.bind(entry.added_at.as_datetime())
|
|
||||||
.bind(*entry.added_by_user_id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
// Entries cascade-delete
|
|
||||||
sqlx::query("DELETE FROM albums WHERE album_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{AssetMetadata, MetadataSource},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::AssetMetadataRepository,
|
|
||||||
value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct AssetMetadataRow {
|
|
||||||
asset_id: Uuid,
|
|
||||||
metadata_source: String,
|
|
||||||
data: serde_json::Value,
|
|
||||||
updated_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_from_str(s: &str) -> MetadataSource {
|
|
||||||
match s {
|
|
||||||
"exif_extracted" => MetadataSource::ExifExtracted,
|
|
||||||
"ai_generated" => MetadataSource::AiGenerated,
|
|
||||||
"user_edited" => MetadataSource::UserEdited,
|
|
||||||
_ => MetadataSource::ExifExtracted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn source_to_str(s: &MetadataSource) -> &'static str {
|
|
||||||
match s {
|
|
||||||
MetadataSource::ExifExtracted => "exif_extracted",
|
|
||||||
MetadataSource::AiGenerated => "ai_generated",
|
|
||||||
MetadataSource::UserEdited => "user_edited",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn json_to_structured_data(v: serde_json::Value) -> StructuredData {
|
|
||||||
let mut sd = StructuredData::new();
|
|
||||||
if let serde_json::Value::Object(map) = v {
|
|
||||||
for (key, val) in map {
|
|
||||||
let mv = match val {
|
|
||||||
serde_json::Value::String(s) => MetadataValue::String(s),
|
|
||||||
serde_json::Value::Number(n) => {
|
|
||||||
if let Some(i) = n.as_i64() {
|
|
||||||
MetadataValue::Integer(i)
|
|
||||||
} else if let Some(f) = n.as_f64() {
|
|
||||||
MetadataValue::Float(f)
|
|
||||||
} else {
|
|
||||||
MetadataValue::Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
serde_json::Value::Bool(b) => MetadataValue::Boolean(b),
|
|
||||||
serde_json::Value::Null => MetadataValue::Null,
|
|
||||||
_ => MetadataValue::String(val.to_string()),
|
|
||||||
};
|
|
||||||
sd.insert(key, mv);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sd
|
|
||||||
}
|
|
||||||
|
|
||||||
fn structured_data_to_json(sd: &StructuredData) -> serde_json::Value {
|
|
||||||
let mut map = serde_json::Map::new();
|
|
||||||
for (key, val) in sd.inner() {
|
|
||||||
let jv = match val {
|
|
||||||
MetadataValue::String(s) => serde_json::Value::String(s.clone()),
|
|
||||||
MetadataValue::Integer(i) => serde_json::Value::Number((*i).into()),
|
|
||||||
MetadataValue::Float(f) => serde_json::Number::from_f64(*f)
|
|
||||||
.map(serde_json::Value::Number)
|
|
||||||
.unwrap_or(serde_json::Value::Null),
|
|
||||||
MetadataValue::Boolean(b) => serde_json::Value::Bool(*b),
|
|
||||||
MetadataValue::Null => serde_json::Value::Null,
|
|
||||||
};
|
|
||||||
map.insert(key.clone(), jv);
|
|
||||||
}
|
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AssetMetadataRow> for AssetMetadata {
|
|
||||||
fn from(r: AssetMetadataRow) -> Self {
|
|
||||||
Self {
|
|
||||||
asset_id: SystemId::from_uuid(r.asset_id),
|
|
||||||
metadata_source: source_from_str(&r.metadata_source),
|
|
||||||
data: json_to_structured_data(r.data),
|
|
||||||
updated_at: DateTimeStamp::from_datetime(r.updated_at),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresAssetMetadataRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresAssetMetadataRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl AssetMetadataRepository for PostgresAssetMetadataRepository {
|
|
||||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<AssetMetadata>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, AssetMetadataRow>(
|
|
||||||
"SELECT asset_id, metadata_source, data, updated_at
|
|
||||||
FROM asset_metadata WHERE asset_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_asset_and_source(
|
|
||||||
&self,
|
|
||||||
asset_id: &SystemId,
|
|
||||||
source: MetadataSource,
|
|
||||||
) -> Result<Option<AssetMetadata>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, AssetMetadataRow>(
|
|
||||||
"SELECT asset_id, metadata_source, data, updated_at
|
|
||||||
FROM asset_metadata WHERE asset_id = $1 AND metadata_source = $2",
|
|
||||||
)
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.bind(source_to_str(&source))
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, metadata: &AssetMetadata) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO asset_metadata (asset_id, metadata_source, data, updated_at)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (asset_id, metadata_source) DO UPDATE SET
|
|
||||||
data = EXCLUDED.data,
|
|
||||||
updated_at = EXCLUDED.updated_at",
|
|
||||||
)
|
|
||||||
.bind(*metadata.asset_id.as_uuid())
|
|
||||||
.bind(source_to_str(&metadata.metadata_source))
|
|
||||||
.bind(structured_data_to_json(&metadata.data))
|
|
||||||
.bind(metadata.updated_at.as_datetime())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_by_asset_and_source(
|
|
||||||
&self,
|
|
||||||
asset_id: &SystemId,
|
|
||||||
source: MetadataSource,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM asset_metadata WHERE asset_id = $1 AND metadata_source = $2")
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.bind(source_to_str(&source))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{Asset, AssetType, SourceReference},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::AssetRepository,
|
|
||||||
value_objects::{Checksum, DateTimeStamp, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct AssetRow {
|
|
||||||
asset_id: Uuid,
|
|
||||||
volume_id: Uuid,
|
|
||||||
relative_path: String,
|
|
||||||
checksum: String,
|
|
||||||
asset_type: String,
|
|
||||||
mime_type: String,
|
|
||||||
file_size: i64,
|
|
||||||
is_processed: bool,
|
|
||||||
owner_user_id: Uuid,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn asset_type_from_str(s: &str) -> AssetType {
|
|
||||||
match s {
|
|
||||||
"image" => AssetType::Image,
|
|
||||||
"video" => AssetType::Video,
|
|
||||||
"live_photo" => AssetType::LivePhoto,
|
|
||||||
_ => AssetType::Image,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn asset_type_to_str(t: &AssetType) -> &'static str {
|
|
||||||
match t {
|
|
||||||
AssetType::Image => "image",
|
|
||||||
AssetType::Video => "video",
|
|
||||||
AssetType::LivePhoto => "live_photo",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<AssetRow> for Asset {
|
|
||||||
type Error = DomainError;
|
|
||||||
fn try_from(r: AssetRow) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
asset_id: SystemId::from_uuid(r.asset_id),
|
|
||||||
source_reference: SourceReference {
|
|
||||||
volume_id: SystemId::from_uuid(r.volume_id),
|
|
||||||
relative_path: r.relative_path,
|
|
||||||
checksum: Checksum::new(r.checksum)?,
|
|
||||||
},
|
|
||||||
asset_type: asset_type_from_str(&r.asset_type),
|
|
||||||
mime_type: r.mime_type,
|
|
||||||
file_size: r.file_size as u64,
|
|
||||||
is_processed: r.is_processed,
|
|
||||||
owner_user_id: SystemId::from_uuid(r.owner_user_id),
|
|
||||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresAssetRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresAssetRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl AssetRepository for PostgresAssetRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, AssetRow>(
|
|
||||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
|
||||||
file_size, is_processed, owner_user_id, created_at
|
|
||||||
FROM assets WHERE asset_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, AssetRow>(
|
|
||||||
"SELECT asset_id, volume_id, relative_path, checksum, asset_type, mime_type,
|
|
||||||
file_size, is_processed, owner_user_id, created_at
|
|
||||||
FROM assets WHERE checksum = $1",
|
|
||||||
)
|
|
||||||
.bind(checksum.as_str())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_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
|
|
||||||
FROM assets WHERE owner_user_id = $1
|
|
||||||
ORDER BY created_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_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO assets (asset_id, volume_id, relative_path, checksum, asset_type,
|
|
||||||
mime_type, file_size, is_processed, owner_user_id, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
ON CONFLICT (asset_id) DO UPDATE SET
|
|
||||||
volume_id = EXCLUDED.volume_id,
|
|
||||||
relative_path = EXCLUDED.relative_path,
|
|
||||||
checksum = EXCLUDED.checksum,
|
|
||||||
asset_type = EXCLUDED.asset_type,
|
|
||||||
mime_type = EXCLUDED.mime_type,
|
|
||||||
file_size = EXCLUDED.file_size,
|
|
||||||
is_processed = EXCLUDED.is_processed,
|
|
||||||
owner_user_id = EXCLUDED.owner_user_id",
|
|
||||||
)
|
|
||||||
.bind(*asset.asset_id.as_uuid())
|
|
||||||
.bind(*asset.source_reference.volume_id.as_uuid())
|
|
||||||
.bind(&asset.source_reference.relative_path)
|
|
||||||
.bind(asset.source_reference.checksum.as_str())
|
|
||||||
.bind(asset_type_to_str(&asset.asset_type))
|
|
||||||
.bind(&asset.mime_type)
|
|
||||||
.bind(asset.file_size as i64)
|
|
||||||
.bind(asset.is_processed)
|
|
||||||
.bind(*asset.owner_user_id.as_uuid())
|
|
||||||
.bind(asset.created_at.as_datetime())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM assets WHERE asset_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1074
crates/adapters/postgres/src/catalog/mod.rs
Normal file
1074
crates/adapters/postgres/src/catalog/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
|||||||
pub type PgPool = sqlx::PgPool;
|
pub type PgPool = sqlx::PgPool;
|
||||||
|
|
||||||
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
|
pub async fn connect(url: &str) -> anyhow::Result<PgPool> {
|
||||||
|
let max_conn: u32 = std::env::var("DB_MAX_CONNECTIONS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(20);
|
||||||
let pool = sqlx::postgres::PgPoolOptions::new()
|
let pool = sqlx::postgres::PgPoolOptions::new()
|
||||||
.max_connections(10)
|
.max_connections(max_conn)
|
||||||
.connect(url)
|
.connect(url)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(pool)
|
Ok(pool)
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{DetectionMethod, DuplicateCandidate, DuplicateGroup, DuplicateStatus},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::DuplicateRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct GroupRow {
|
|
||||||
group_id: Uuid,
|
|
||||||
detection_method: String,
|
|
||||||
status: String,
|
|
||||||
candidates: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detection_from_str(s: &str) -> DetectionMethod {
|
|
||||||
match s {
|
|
||||||
"perceptual_hash" => DetectionMethod::PerceptualHash,
|
|
||||||
_ => DetectionMethod::ExactHash,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detection_to_str(d: &DetectionMethod) -> &'static str {
|
|
||||||
match d {
|
|
||||||
DetectionMethod::ExactHash => "exact_hash",
|
|
||||||
DetectionMethod::PerceptualHash => "perceptual_hash",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dup_status_from_str(s: &str) -> DuplicateStatus {
|
|
||||||
match s {
|
|
||||||
"resolved" => DuplicateStatus::Resolved,
|
|
||||||
_ => DuplicateStatus::Unresolved,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dup_status_to_str(s: &DuplicateStatus) -> &'static str {
|
|
||||||
match s {
|
|
||||||
DuplicateStatus::Unresolved => "unresolved",
|
|
||||||
DuplicateStatus::Resolved => "resolved",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct CandidateJson {
|
|
||||||
asset_id: Uuid,
|
|
||||||
similarity_score: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidates_from_json(v: serde_json::Value) -> Vec<DuplicateCandidate> {
|
|
||||||
let arr: Vec<CandidateJson> = serde_json::from_value(v).unwrap_or_default();
|
|
||||||
arr.into_iter()
|
|
||||||
.map(|c| DuplicateCandidate {
|
|
||||||
asset_id: SystemId::from_uuid(c.asset_id),
|
|
||||||
similarity_score: c.similarity_score,
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidates_to_json(candidates: &[DuplicateCandidate]) -> serde_json::Value {
|
|
||||||
let arr: Vec<CandidateJson> = candidates
|
|
||||||
.iter()
|
|
||||||
.map(|c| CandidateJson {
|
|
||||||
asset_id: *c.asset_id.as_uuid(),
|
|
||||||
similarity_score: c.similarity_score,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<GroupRow> for DuplicateGroup {
|
|
||||||
fn from(r: GroupRow) -> Self {
|
|
||||||
Self {
|
|
||||||
group_id: SystemId::from_uuid(r.group_id),
|
|
||||||
detection_method: detection_from_str(&r.detection_method),
|
|
||||||
status: dup_status_from_str(&r.status),
|
|
||||||
candidates: candidates_from_json(r.candidates),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresDuplicateRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresDuplicateRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl DuplicateRepository for PostgresDuplicateRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<DuplicateGroup>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, GroupRow>(
|
|
||||||
"SELECT group_id, detection_method, status, candidates
|
|
||||||
FROM duplicate_groups WHERE group_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_unresolved(&self) -> Result<Vec<DuplicateGroup>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, GroupRow>(
|
|
||||||
"SELECT group_id, detection_method, status, candidates
|
|
||||||
FROM duplicate_groups WHERE status = 'unresolved'",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_asset(&self, asset_id: &SystemId) -> Result<Vec<DuplicateGroup>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, GroupRow>(
|
|
||||||
"SELECT group_id, detection_method, status, candidates
|
|
||||||
FROM duplicate_groups WHERE candidates @> $1::jsonb",
|
|
||||||
)
|
|
||||||
.bind(serde_json::json!([{"asset_id": asset_id.as_uuid()}]))
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, group: &DuplicateGroup) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO duplicate_groups (group_id, detection_method, status, candidates)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (group_id) DO UPDATE SET
|
|
||||||
detection_method = EXCLUDED.detection_method,
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
candidates = EXCLUDED.candidates",
|
|
||||||
)
|
|
||||||
.bind(*group.group_id.as_uuid())
|
|
||||||
.bind(detection_to_str(&group.detection_method))
|
|
||||||
.bind(dup_status_to_str(&group.status))
|
|
||||||
.bind(candidates_to_json(&group.candidates))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
83
crates/adapters/postgres/src/event_store.rs
Normal file
83
crates/adapters/postgres/src/event_store.rs
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
|
use adapters_event_payload::EventPayload;
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError, events::DomainEvent, ports::EventStore, value_objects::SystemId,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pg_repo!(PostgresEventStore);
|
||||||
|
|
||||||
|
/// Extracts the primary aggregate ID from a domain event.
|
||||||
|
fn aggregate_id(event: &DomainEvent) -> Uuid {
|
||||||
|
match event {
|
||||||
|
DomainEvent::AssetIngested { asset_id, .. }
|
||||||
|
| DomainEvent::MetadataUpdated { asset_id, .. }
|
||||||
|
| DomainEvent::AssetDeleted { asset_id, .. }
|
||||||
|
| DomainEvent::SidecarSyncRequested { asset_id, .. }
|
||||||
|
| DomainEvent::DerivativeGenerated { asset_id, .. } => *asset_id.as_uuid(),
|
||||||
|
|
||||||
|
DomainEvent::ShareCreated { scope_id, .. } | DomainEvent::ShareRevoked { scope_id, .. } => {
|
||||||
|
*scope_id.as_uuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::JobEnqueued { job_id, .. }
|
||||||
|
| DomainEvent::JobCompleted { job_id, .. }
|
||||||
|
| DomainEvent::JobFailed { job_id, .. } => *job_id.as_uuid(),
|
||||||
|
|
||||||
|
DomainEvent::UserCreated { user_id, .. } | DomainEvent::UserDeleted { user_id, .. } => {
|
||||||
|
*user_id.as_uuid()
|
||||||
|
}
|
||||||
|
|
||||||
|
DomainEvent::AlbumCreated { album_id, .. } => *album_id.as_uuid(),
|
||||||
|
|
||||||
|
DomainEvent::TagCreated { tag_id, .. } => *tag_id.as_uuid(),
|
||||||
|
|
||||||
|
DomainEvent::DuplicateDetected { group_id, .. } => *group_id.as_uuid(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventStore for PostgresEventStore {
|
||||||
|
async fn append(&self, event: &DomainEvent) -> Result<(), DomainError> {
|
||||||
|
let payload = EventPayload::from(event);
|
||||||
|
let event_type = payload.subject().to_string();
|
||||||
|
let json =
|
||||||
|
serde_json::to_value(&payload).map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
let agg_id = aggregate_id(event);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO event_log (aggregate_id, event_type, payload, occurred_at)
|
||||||
|
VALUES ($1, $2, $3, now())",
|
||||||
|
)
|
||||||
|
.bind(agg_id)
|
||||||
|
.bind(event_type)
|
||||||
|
.bind(json)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn query_by_aggregate(
|
||||||
|
&self,
|
||||||
|
aggregate_id: &SystemId,
|
||||||
|
) -> Result<Vec<DomainEvent>, DomainError> {
|
||||||
|
let rows: Vec<(serde_json::Value,)> = sqlx::query_as(
|
||||||
|
"SELECT payload FROM event_log WHERE aggregate_id = $1 ORDER BY event_id ASC",
|
||||||
|
)
|
||||||
|
.bind(*aggregate_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
rows.into_iter()
|
||||||
|
.map(|(json,)| {
|
||||||
|
let payload: EventPayload = serde_json::from_value(json)
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
DomainEvent::try_from(payload)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
45
crates/adapters/postgres/src/helpers.rs
Normal file
45
crates/adapters/postgres/src/helpers.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
|
pub trait MapDomainError<T> {
|
||||||
|
fn map_pg(self) -> Result<T, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> MapDomainError<T> for Result<T, sqlx::Error> {
|
||||||
|
fn map_pg(self) -> Result<T, DomainError> {
|
||||||
|
self.map_err(|e| match &e {
|
||||||
|
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23505") => {
|
||||||
|
DomainError::Conflict(
|
||||||
|
db_err
|
||||||
|
.constraint()
|
||||||
|
.map(|c| format!("Duplicate: {c}"))
|
||||||
|
.unwrap_or_else(|| "Duplicate entry".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some("23503") => {
|
||||||
|
DomainError::NotFound(
|
||||||
|
db_err
|
||||||
|
.constraint()
|
||||||
|
.map(|c| format!("Referenced entity not found: {c}"))
|
||||||
|
.unwrap_or_else(|| "Referenced entity not found".into()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => DomainError::Internal(e.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! pg_repo {
|
||||||
|
($name:ident) => {
|
||||||
|
pub struct $name {
|
||||||
|
pool: crate::db::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
pub fn new(pool: crate::db::PgPool) -> Self {
|
||||||
|
Self { pool }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) use pg_repo;
|
||||||
203
crates/adapters/postgres/src/identity/mod.rs
Normal file
203
crates/adapters/postgres/src/identity/mod.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
entities::RefreshToken,
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{RefreshTokenRepository, UserRepository},
|
||||||
|
value_objects::{DateTimeStamp, Email, PasswordHash, SystemId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct UserRow {
|
||||||
|
id: Uuid,
|
||||||
|
username: String,
|
||||||
|
email: String,
|
||||||
|
password_hash: String,
|
||||||
|
role: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<UserRow> for domain::entities::User {
|
||||||
|
type Error = DomainError;
|
||||||
|
fn try_from(r: UserRow) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
id: SystemId::from_uuid(r.id),
|
||||||
|
username: r.username,
|
||||||
|
email: Email::new(r.email)?,
|
||||||
|
password_hash: PasswordHash::from_hash(r.password_hash),
|
||||||
|
role: r.role,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresUserRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserRepository for PostgresUserRepository {
|
||||||
|
async fn find_by_id(
|
||||||
|
&self,
|
||||||
|
id: &SystemId,
|
||||||
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_email(
|
||||||
|
&self,
|
||||||
|
email: &Email,
|
||||||
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE email = $1",
|
||||||
|
)
|
||||||
|
.bind(email.as_str())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_username(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<Option<domain::entities::User>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, UserRow>(
|
||||||
|
"SELECT id, username, email, password_hash, role, created_at FROM users WHERE username = $1",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
|
||||||
|
sqlx::query_as::<_, UserRow>(
|
||||||
|
"INSERT INTO users (id, username, email, password_hash, role, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
role = EXCLUDED.role
|
||||||
|
RETURNING id, username, email, password_hash, role, created_at",
|
||||||
|
)
|
||||||
|
.bind(*user.id.as_uuid())
|
||||||
|
.bind(&user.username)
|
||||||
|
.bind(user.email.as_str())
|
||||||
|
.bind(user.password_hash.as_str())
|
||||||
|
.bind(&user.role)
|
||||||
|
.bind(user.created_at)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM users WHERE id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(&self) -> Result<u64, DomainError> {
|
||||||
|
let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(count as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PostgresRefreshTokenRepository ---
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct RefreshTokenRow {
|
||||||
|
token_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
token_hash: String,
|
||||||
|
expires_at: DateTime<Utc>,
|
||||||
|
revoked: bool,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RefreshTokenRow> for RefreshToken {
|
||||||
|
fn from(r: RefreshTokenRow) -> Self {
|
||||||
|
Self {
|
||||||
|
token_id: SystemId::from_uuid(r.token_id),
|
||||||
|
user_id: SystemId::from_uuid(r.user_id),
|
||||||
|
token_hash: r.token_hash,
|
||||||
|
expires_at: DateTimeStamp::from_datetime(r.expires_at),
|
||||||
|
revoked: r.revoked,
|
||||||
|
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresRefreshTokenRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl RefreshTokenRepository for PostgresRefreshTokenRepository {
|
||||||
|
async fn save(&self, token: &RefreshToken) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO refresh_tokens (token_id, user_id, token_hash, expires_at, revoked, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (token_id) DO UPDATE SET revoked = EXCLUDED.revoked",
|
||||||
|
)
|
||||||
|
.bind(*token.token_id.as_uuid())
|
||||||
|
.bind(*token.user_id.as_uuid())
|
||||||
|
.bind(&token.token_hash)
|
||||||
|
.bind(*token.expires_at.as_datetime())
|
||||||
|
.bind(token.revoked)
|
||||||
|
.bind(*token.created_at.as_datetime())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_hash(&self, token_hash: &str) -> Result<Option<RefreshToken>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, RefreshTokenRow>(
|
||||||
|
"SELECT token_id, user_id, token_hash, expires_at, revoked, created_at
|
||||||
|
FROM refresh_tokens WHERE token_hash = $1",
|
||||||
|
)
|
||||||
|
.bind(token_hash)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_by_user(&self, user_id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_tokens WHERE user_id = $1")
|
||||||
|
.bind(*user_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM refresh_tokens WHERE token_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{IngestSession, IngestStatus},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::IngestSessionRepository,
|
|
||||||
value_objects::{Checksum, DateTimeStamp, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct IngestSessionRow {
|
|
||||||
session_id: Uuid,
|
|
||||||
uploader_user_id: Uuid,
|
|
||||||
client_device_id: String,
|
|
||||||
original_filename: String,
|
|
||||||
client_checksum: String,
|
|
||||||
target_library_path_id: Uuid,
|
|
||||||
status: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
error_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_from_str(s: &str) -> IngestStatus {
|
|
||||||
match s {
|
|
||||||
"uploading" => IngestStatus::Uploading,
|
|
||||||
"awaiting_processing" => IngestStatus::AwaitingProcessing,
|
|
||||||
"processing" => IngestStatus::Processing,
|
|
||||||
"completed" => IngestStatus::Completed,
|
|
||||||
"failed" => IngestStatus::Failed,
|
|
||||||
_ => IngestStatus::Uploading,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_to_str(s: &IngestStatus) -> &'static str {
|
|
||||||
match s {
|
|
||||||
IngestStatus::Uploading => "uploading",
|
|
||||||
IngestStatus::AwaitingProcessing => "awaiting_processing",
|
|
||||||
IngestStatus::Processing => "processing",
|
|
||||||
IngestStatus::Completed => "completed",
|
|
||||||
IngestStatus::Failed => "failed",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<IngestSessionRow> for IngestSession {
|
|
||||||
type Error = DomainError;
|
|
||||||
fn try_from(r: IngestSessionRow) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
session_id: SystemId::from_uuid(r.session_id),
|
|
||||||
uploader_user_id: SystemId::from_uuid(r.uploader_user_id),
|
|
||||||
client_device_id: r.client_device_id,
|
|
||||||
original_filename: r.original_filename,
|
|
||||||
client_checksum: Checksum::new(r.client_checksum)?,
|
|
||||||
target_library_path_id: SystemId::from_uuid(r.target_library_path_id),
|
|
||||||
status: status_from_str(&r.status),
|
|
||||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
|
||||||
error_message: r.error_message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresIngestSessionRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresIngestSessionRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl IngestSessionRepository for PostgresIngestSessionRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<IngestSession>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, IngestSessionRow>(
|
|
||||||
"SELECT session_id, uploader_user_id, client_device_id, original_filename,
|
|
||||||
client_checksum, target_library_path_id, status, created_at, error_message
|
|
||||||
FROM ingest_sessions WHERE session_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<IngestSession>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, IngestSessionRow>(
|
|
||||||
"SELECT session_id, uploader_user_id, client_device_id, original_filename,
|
|
||||||
client_checksum, target_library_path_id, status, created_at, error_message
|
|
||||||
FROM ingest_sessions WHERE uploader_user_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*user_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, session: &IngestSession) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO ingest_sessions (session_id, uploader_user_id, client_device_id, original_filename,
|
|
||||||
client_checksum, target_library_path_id, status, created_at, error_message)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
ON CONFLICT (session_id) DO UPDATE SET
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
error_message = EXCLUDED.error_message",
|
|
||||||
)
|
|
||||||
.bind(*session.session_id.as_uuid())
|
|
||||||
.bind(*session.uploader_user_id.as_uuid())
|
|
||||||
.bind(&session.client_device_id)
|
|
||||||
.bind(&session.original_filename)
|
|
||||||
.bind(session.client_checksum.as_str())
|
|
||||||
.bind(*session.target_library_path_id.as_uuid())
|
|
||||||
.bind(status_to_str(&session.status))
|
|
||||||
.bind(session.created_at.as_datetime())
|
|
||||||
.bind(session.error_message.as_deref())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{BatchStatus, JobBatch},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::JobBatchRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct BatchRow {
|
|
||||||
batch_id: Uuid,
|
|
||||||
batch_type: String,
|
|
||||||
total_jobs: i32,
|
|
||||||
completed_count: i32,
|
|
||||||
failed_count: i32,
|
|
||||||
status: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_status_from_str(s: &str) -> BatchStatus {
|
|
||||||
match s {
|
|
||||||
"in_progress" => BatchStatus::InProgress,
|
|
||||||
"completed_with_errors" => BatchStatus::CompletedWithErrors,
|
|
||||||
"completed" => BatchStatus::Completed,
|
|
||||||
"cancelled" => BatchStatus::Cancelled,
|
|
||||||
_ => BatchStatus::InProgress,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn batch_status_to_str(s: &BatchStatus) -> &'static str {
|
|
||||||
match s {
|
|
||||||
BatchStatus::InProgress => "in_progress",
|
|
||||||
BatchStatus::CompletedWithErrors => "completed_with_errors",
|
|
||||||
BatchStatus::Completed => "completed",
|
|
||||||
BatchStatus::Cancelled => "cancelled",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<BatchRow> for JobBatch {
|
|
||||||
fn from(r: BatchRow) -> Self {
|
|
||||||
Self {
|
|
||||||
batch_id: SystemId::from_uuid(r.batch_id),
|
|
||||||
batch_type: r.batch_type,
|
|
||||||
total_jobs: r.total_jobs as u32,
|
|
||||||
completed_count: r.completed_count as u32,
|
|
||||||
failed_count: r.failed_count as u32,
|
|
||||||
status: batch_status_from_str(&r.status),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresJobBatchRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresJobBatchRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl JobBatchRepository for PostgresJobBatchRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<JobBatch>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, BatchRow>(
|
|
||||||
"SELECT batch_id, batch_type, total_jobs, completed_count, failed_count, status
|
|
||||||
FROM job_batches WHERE batch_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, batch: &JobBatch) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO job_batches (batch_id, batch_type, total_jobs, completed_count, failed_count, status)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (batch_id) DO UPDATE SET
|
|
||||||
total_jobs = EXCLUDED.total_jobs,
|
|
||||||
completed_count = EXCLUDED.completed_count,
|
|
||||||
failed_count = EXCLUDED.failed_count,
|
|
||||||
status = EXCLUDED.status",
|
|
||||||
)
|
|
||||||
.bind(*batch.batch_id.as_uuid())
|
|
||||||
.bind(&batch.batch_type)
|
|
||||||
.bind(batch.total_jobs as i32)
|
|
||||||
.bind(batch.completed_count as i32)
|
|
||||||
.bind(batch.failed_count as i32)
|
|
||||||
.bind(batch_status_to_str(&batch.status))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{Job, JobStatus, JobType},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::JobRepository,
|
|
||||||
value_objects::{DateTimeStamp, StructuredData, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct JobRow {
|
|
||||||
job_id: Uuid,
|
|
||||||
job_type: String,
|
|
||||||
target_asset_id: Option<Uuid>,
|
|
||||||
batch_id: Option<Uuid>,
|
|
||||||
status: String,
|
|
||||||
priority: i32,
|
|
||||||
payload: serde_json::Value,
|
|
||||||
result_data: Option<serde_json::Value>,
|
|
||||||
retry_count: i32,
|
|
||||||
max_retries: i32,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
started_at: Option<DateTime<Utc>>,
|
|
||||||
completed_at: Option<DateTime<Utc>>,
|
|
||||||
error_message: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn job_type_from_str(s: &str) -> JobType {
|
|
||||||
match s {
|
|
||||||
"scan_directory" => JobType::ScanDirectory,
|
|
||||||
"extract_metadata" => JobType::ExtractMetadata,
|
|
||||||
"generate_derivative" => JobType::GenerateDerivative,
|
|
||||||
"sync_sidecar" => JobType::SyncSidecar,
|
|
||||||
"detect_duplicates" => JobType::DetectDuplicates,
|
|
||||||
other => JobType::Custom(other.to_string()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn job_type_to_str(t: &JobType) -> String {
|
|
||||||
match t {
|
|
||||||
JobType::ScanDirectory => "scan_directory".to_string(),
|
|
||||||
JobType::ExtractMetadata => "extract_metadata".to_string(),
|
|
||||||
JobType::GenerateDerivative => "generate_derivative".to_string(),
|
|
||||||
JobType::SyncSidecar => "sync_sidecar".to_string(),
|
|
||||||
JobType::DetectDuplicates => "detect_duplicates".to_string(),
|
|
||||||
JobType::Custom(s) => s.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn job_status_from_str(s: &str) -> JobStatus {
|
|
||||||
match s {
|
|
||||||
"queued" => JobStatus::Queued,
|
|
||||||
"processing" => JobStatus::Processing,
|
|
||||||
"completed" => JobStatus::Completed,
|
|
||||||
"failed" => JobStatus::Failed,
|
|
||||||
"cancelled" => JobStatus::Cancelled,
|
|
||||||
_ => JobStatus::Queued,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn job_status_to_str(s: &JobStatus) -> &'static str {
|
|
||||||
match s {
|
|
||||||
JobStatus::Queued => "queued",
|
|
||||||
JobStatus::Processing => "processing",
|
|
||||||
JobStatus::Completed => "completed",
|
|
||||||
JobStatus::Failed => "failed",
|
|
||||||
JobStatus::Cancelled => "cancelled",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn structured_from_json(v: serde_json::Value) -> StructuredData {
|
|
||||||
if let serde_json::Value::Object(map) = v {
|
|
||||||
let mut sd = StructuredData::new();
|
|
||||||
for (k, val) in map {
|
|
||||||
sd.insert(k, domain::value_objects::MetadataValue::from(val));
|
|
||||||
}
|
|
||||||
sd
|
|
||||||
} else {
|
|
||||||
StructuredData::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn structured_to_json(sd: &StructuredData) -> serde_json::Value {
|
|
||||||
let map: serde_json::Map<String, serde_json::Value> = sd
|
|
||||||
.inner()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), serde_json::Value::from(v)))
|
|
||||||
.collect();
|
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<JobRow> for Job {
|
|
||||||
fn from(r: JobRow) -> Self {
|
|
||||||
Self {
|
|
||||||
job_id: SystemId::from_uuid(r.job_id),
|
|
||||||
job_type: job_type_from_str(&r.job_type),
|
|
||||||
target_asset_id: r.target_asset_id.map(SystemId::from_uuid),
|
|
||||||
batch_id: r.batch_id.map(SystemId::from_uuid),
|
|
||||||
status: job_status_from_str(&r.status),
|
|
||||||
priority: r.priority as u32,
|
|
||||||
payload: structured_from_json(r.payload),
|
|
||||||
result_data: r.result_data.map(structured_from_json),
|
|
||||||
retry_count: r.retry_count as u32,
|
|
||||||
max_retries: r.max_retries as u32,
|
|
||||||
created_at: DateTimeStamp::from_datetime(r.created_at),
|
|
||||||
started_at: r.started_at.map(DateTimeStamp::from_datetime),
|
|
||||||
completed_at: r.completed_at.map(DateTimeStamp::from_datetime),
|
|
||||||
error_message: r.error_message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresJobRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresJobRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl JobRepository for PostgresJobRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError> {
|
|
||||||
let row = 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 job_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError> {
|
|
||||||
let row = 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 = 'queued'
|
|
||||||
ORDER BY priority DESC, created_at ASC
|
|
||||||
LIMIT 1",
|
|
||||||
)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> {
|
|
||||||
let rows = 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 batch_id = $1
|
|
||||||
ORDER BY created_at ASC",
|
|
||||||
)
|
|
||||||
.bind(*batch_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, job: &Job) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO jobs (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)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
|
||||||
ON CONFLICT (job_id) DO UPDATE SET
|
|
||||||
status = EXCLUDED.status,
|
|
||||||
priority = EXCLUDED.priority,
|
|
||||||
payload = EXCLUDED.payload,
|
|
||||||
result_data = EXCLUDED.result_data,
|
|
||||||
retry_count = EXCLUDED.retry_count,
|
|
||||||
started_at = EXCLUDED.started_at,
|
|
||||||
completed_at = EXCLUDED.completed_at,
|
|
||||||
error_message = EXCLUDED.error_message",
|
|
||||||
)
|
|
||||||
.bind(*job.job_id.as_uuid())
|
|
||||||
.bind(job_type_to_str(&job.job_type))
|
|
||||||
.bind(job.target_asset_id.as_ref().map(|id| *id.as_uuid()))
|
|
||||||
.bind(job.batch_id.as_ref().map(|id| *id.as_uuid()))
|
|
||||||
.bind(job_status_to_str(&job.status))
|
|
||||||
.bind(job.priority as i32)
|
|
||||||
.bind(structured_to_json(&job.payload))
|
|
||||||
.bind(job.result_data.as_ref().map(structured_to_json))
|
|
||||||
.bind(job.retry_count as i32)
|
|
||||||
.bind(job.max_retries as i32)
|
|
||||||
.bind(job.created_at.as_datetime())
|
|
||||||
.bind(job.started_at.as_ref().map(|d| d.as_datetime()))
|
|
||||||
.bind(job.completed_at.as_ref().map(|d| d.as_datetime()))
|
|
||||||
.bind(&job.error_message)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +1,22 @@
|
|||||||
pub mod db;
|
pub mod db;
|
||||||
|
mod helpers;
|
||||||
|
|
||||||
pub mod album_repository;
|
pub mod catalog;
|
||||||
pub mod asset_metadata_repository;
|
pub mod event_store;
|
||||||
pub mod asset_repository;
|
pub mod identity;
|
||||||
pub mod duplicate_repository;
|
pub mod organization;
|
||||||
pub mod ingest_session_repository;
|
pub mod processing;
|
||||||
pub mod job_batch_repository;
|
pub mod sharing;
|
||||||
pub mod job_repository;
|
pub mod sidecar;
|
||||||
pub mod library_path_repository;
|
pub mod storage;
|
||||||
pub mod pipeline_repository;
|
|
||||||
pub mod plugin_repository;
|
|
||||||
pub mod quota_repository;
|
|
||||||
pub mod share_repository;
|
|
||||||
pub mod sidecar_repository;
|
|
||||||
pub mod storage_volume_repository;
|
|
||||||
pub mod tag_repository;
|
|
||||||
pub mod user_repository;
|
|
||||||
pub mod visibility_filter_repository;
|
|
||||||
|
|
||||||
pub use db::{PgPool, connect, run_migrations};
|
pub use db::{PgPool, connect, run_migrations};
|
||||||
|
|
||||||
pub use album_repository::PostgresAlbumRepository;
|
pub use catalog::*;
|
||||||
pub use asset_metadata_repository::PostgresAssetMetadataRepository;
|
pub use event_store::PostgresEventStore;
|
||||||
pub use asset_repository::PostgresAssetRepository;
|
pub use identity::*;
|
||||||
pub use duplicate_repository::PostgresDuplicateRepository;
|
pub use organization::*;
|
||||||
pub use ingest_session_repository::PostgresIngestSessionRepository;
|
pub use processing::*;
|
||||||
pub use job_batch_repository::PostgresJobBatchRepository;
|
pub use sharing::*;
|
||||||
pub use job_repository::PostgresJobRepository;
|
pub use sidecar::*;
|
||||||
pub use library_path_repository::PostgresLibraryPathRepository;
|
pub use storage::*;
|
||||||
pub use pipeline_repository::PostgresPipelineRepository;
|
|
||||||
pub use plugin_repository::PostgresPluginRepository;
|
|
||||||
pub use quota_repository::{PostgresQuotaRepository, PostgresUsageLedgerRepository};
|
|
||||||
pub use share_repository::PostgresShareRepository;
|
|
||||||
pub use sidecar_repository::PostgresSidecarRepository;
|
|
||||||
pub use storage_volume_repository::PostgresStorageVolumeRepository;
|
|
||||||
pub use tag_repository::PostgresTagRepository;
|
|
||||||
pub use user_repository::PostgresUserRepository;
|
|
||||||
pub use visibility_filter_repository::PostgresVisibilityFilterRepository;
|
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{LibraryPath, OwnershipPolicy},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::LibraryPathRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct LibraryPathRow {
|
|
||||||
path_id: Uuid,
|
|
||||||
volume_id: Uuid,
|
|
||||||
relative_path: String,
|
|
||||||
is_ingest_destination: bool,
|
|
||||||
ownership_policy: String,
|
|
||||||
designated_owner_id: Option<Uuid>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn policy_from_str(s: &str) -> OwnershipPolicy {
|
|
||||||
match s {
|
|
||||||
"user_owned" => OwnershipPolicy::UserOwned,
|
|
||||||
"group_owned" => OwnershipPolicy::GroupOwned,
|
|
||||||
_ => OwnershipPolicy::Unassigned,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn policy_to_str(p: &OwnershipPolicy) -> &'static str {
|
|
||||||
match p {
|
|
||||||
OwnershipPolicy::UserOwned => "user_owned",
|
|
||||||
OwnershipPolicy::GroupOwned => "group_owned",
|
|
||||||
OwnershipPolicy::Unassigned => "unassigned",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<LibraryPathRow> for LibraryPath {
|
|
||||||
fn from(r: LibraryPathRow) -> Self {
|
|
||||||
Self {
|
|
||||||
path_id: SystemId::from_uuid(r.path_id),
|
|
||||||
volume_id: SystemId::from_uuid(r.volume_id),
|
|
||||||
relative_path: r.relative_path,
|
|
||||||
is_ingest_destination: r.is_ingest_destination,
|
|
||||||
ownership_policy: policy_from_str(&r.ownership_policy),
|
|
||||||
designated_owner_id: r.designated_owner_id.map(SystemId::from_uuid),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresLibraryPathRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresLibraryPathRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl LibraryPathRepository for PostgresLibraryPathRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, LibraryPathRow>(
|
|
||||||
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
|
||||||
FROM library_paths WHERE path_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_volume(&self, volume_id: &SystemId) -> 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 WHERE volume_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*volume_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_ingest_destinations(
|
|
||||||
&self,
|
|
||||||
owner_id: &SystemId,
|
|
||||||
) -> 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
|
|
||||||
WHERE is_ingest_destination = true AND designated_owner_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*owner_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, path: &LibraryPath) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO library_paths (path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
ON CONFLICT (path_id) DO UPDATE SET
|
|
||||||
volume_id = EXCLUDED.volume_id,
|
|
||||||
relative_path = EXCLUDED.relative_path,
|
|
||||||
is_ingest_destination = EXCLUDED.is_ingest_destination,
|
|
||||||
ownership_policy = EXCLUDED.ownership_policy,
|
|
||||||
designated_owner_id = EXCLUDED.designated_owner_id",
|
|
||||||
)
|
|
||||||
.bind(*path.path_id.as_uuid())
|
|
||||||
.bind(*path.volume_id.as_uuid())
|
|
||||||
.bind(&path.relative_path)
|
|
||||||
.bind(path.is_ingest_destination)
|
|
||||||
.bind(policy_to_str(&path.ownership_policy))
|
|
||||||
.bind(path.designated_owner_id.as_ref().map(|id| *id.as_uuid()))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM library_paths WHERE path_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
340
crates/adapters/postgres/src/organization/mod.rs
Normal file
340
crates/adapters/postgres/src/organization/mod.rs
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
entities::{Album, AlbumEntry, AssetTag, Tag, TagSource},
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AlbumRepository, TagRepository},
|
||||||
|
value_objects::{DateTimeStamp, SystemId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Album
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AlbumRow {
|
||||||
|
album_id: Uuid,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
creator_user_id: Uuid,
|
||||||
|
cover_asset_id: Option<Uuid>,
|
||||||
|
start_date: Option<DateTime<Utc>>,
|
||||||
|
end_date: Option<DateTime<Utc>>,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct AlbumEntryRow {
|
||||||
|
album_id: Uuid,
|
||||||
|
asset_id: Uuid,
|
||||||
|
sort_order: i32,
|
||||||
|
added_at: DateTime<Utc>,
|
||||||
|
added_by_user_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AlbumEntryRow> for AlbumEntry {
|
||||||
|
fn from(r: AlbumEntryRow) -> Self {
|
||||||
|
Self {
|
||||||
|
asset_id: SystemId::from_uuid(r.asset_id),
|
||||||
|
sort_order: r.sort_order as u32,
|
||||||
|
added_at: DateTimeStamp::from_datetime(r.added_at),
|
||||||
|
added_by_user_id: SystemId::from_uuid(r.added_by_user_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn album_from_row(r: AlbumRow, entries: Vec<AlbumEntry>) -> Album {
|
||||||
|
Album {
|
||||||
|
album_id: SystemId::from_uuid(r.album_id),
|
||||||
|
title: r.title,
|
||||||
|
description: r.description,
|
||||||
|
creator_user_id: SystemId::from_uuid(r.creator_user_id),
|
||||||
|
cover_asset_id: r.cover_asset_id.map(SystemId::from_uuid),
|
||||||
|
start_date: r.start_date.map(DateTimeStamp::from_datetime),
|
||||||
|
end_date: r.end_date.map(DateTimeStamp::from_datetime),
|
||||||
|
entries,
|
||||||
|
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresAlbumRepository);
|
||||||
|
|
||||||
|
impl PostgresAlbumRepository {
|
||||||
|
async fn load_entries(&self, album_id: &Uuid) -> Result<Vec<AlbumEntry>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, AlbumEntryRow>(
|
||||||
|
"SELECT album_id, asset_id, sort_order, added_at, added_by_user_id
|
||||||
|
FROM album_entries WHERE album_id = $1
|
||||||
|
ORDER BY sort_order ASC",
|
||||||
|
)
|
||||||
|
.bind(album_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AlbumRepository for PostgresAlbumRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Album>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, AlbumRow>(
|
||||||
|
"SELECT album_id, title, description, creator_user_id, cover_asset_id,
|
||||||
|
start_date, end_date, created_at
|
||||||
|
FROM albums WHERE album_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
let Some(r) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let entries = self.load_entries(&r.album_id).await?;
|
||||||
|
Ok(Some(album_from_row(r, entries)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_creator(&self, creator_id: &SystemId) -> Result<Vec<Album>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, AlbumRow>(
|
||||||
|
"SELECT album_id, title, description, creator_user_id, cover_asset_id,
|
||||||
|
start_date, end_date, created_at
|
||||||
|
FROM albums WHERE creator_user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*creator_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
let mut albums = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
let entries = self.load_entries(&r.album_id).await?;
|
||||||
|
albums.push(album_from_row(r, entries));
|
||||||
|
}
|
||||||
|
Ok(albums)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, album: &Album) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO albums (album_id, title, description, creator_user_id, cover_asset_id,
|
||||||
|
start_date, end_date, created_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (album_id) DO UPDATE SET
|
||||||
|
title = EXCLUDED.title,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
cover_asset_id = EXCLUDED.cover_asset_id,
|
||||||
|
start_date = EXCLUDED.start_date,
|
||||||
|
end_date = EXCLUDED.end_date",
|
||||||
|
)
|
||||||
|
.bind(*album.album_id.as_uuid())
|
||||||
|
.bind(&album.title)
|
||||||
|
.bind(&album.description)
|
||||||
|
.bind(*album.creator_user_id.as_uuid())
|
||||||
|
.bind(album.cover_asset_id.as_ref().map(|id| *id.as_uuid()))
|
||||||
|
.bind(album.start_date.as_ref().map(|d| d.as_datetime()).copied())
|
||||||
|
.bind(album.end_date.as_ref().map(|d| d.as_datetime()).copied())
|
||||||
|
.bind(album.created_at.as_datetime())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
// Sync entries: delete all then re-insert
|
||||||
|
sqlx::query("DELETE FROM album_entries WHERE album_id = $1")
|
||||||
|
.bind(*album.album_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
for entry in &album.entries {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO album_entries (album_id, asset_id, sort_order, added_at, added_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
)
|
||||||
|
.bind(*album.album_id.as_uuid())
|
||||||
|
.bind(*entry.asset_id.as_uuid())
|
||||||
|
.bind(entry.sort_order as i32)
|
||||||
|
.bind(entry.added_at.as_datetime())
|
||||||
|
.bind(*entry.added_by_user_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
// Entries cascade-delete
|
||||||
|
sqlx::query("DELETE FROM albums WHERE album_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Tag
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct TagRow {
|
||||||
|
tag_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
tag_source: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct AssetTagRow {
|
||||||
|
asset_id: Uuid,
|
||||||
|
tag_id: Uuid,
|
||||||
|
tagged_by_user_id: Option<Uuid>,
|
||||||
|
confidence: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_source_from_str(s: &str) -> TagSource {
|
||||||
|
match s {
|
||||||
|
"ai_generated" => TagSource::AiGenerated,
|
||||||
|
"exif_extracted" => TagSource::ExifExtracted,
|
||||||
|
_ => TagSource::UserManual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_source_to_str(s: &TagSource) -> &'static str {
|
||||||
|
match s {
|
||||||
|
TagSource::UserManual => "user_manual",
|
||||||
|
TagSource::AiGenerated => "ai_generated",
|
||||||
|
TagSource::ExifExtracted => "exif_extracted",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<TagRow> for Tag {
|
||||||
|
fn from(r: TagRow) -> Self {
|
||||||
|
Self {
|
||||||
|
tag_id: SystemId::from_uuid(r.tag_id),
|
||||||
|
name: r.name,
|
||||||
|
tag_source: tag_source_from_str(&r.tag_source),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AssetTagRow> for AssetTag {
|
||||||
|
fn from(r: AssetTagRow) -> Self {
|
||||||
|
Self {
|
||||||
|
asset_id: SystemId::from_uuid(r.asset_id),
|
||||||
|
tag_id: SystemId::from_uuid(r.tag_id),
|
||||||
|
tagged_by_user_id: r.tagged_by_user_id.map(SystemId::from_uuid),
|
||||||
|
confidence: r.confidence,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresTagRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl TagRepository for PostgresTagRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Tag>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, TagRow>(
|
||||||
|
"SELECT tag_id, name, tag_source FROM tags WHERE tag_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_name(&self, name: &str) -> Result<Option<Tag>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, TagRow>(
|
||||||
|
"SELECT tag_id, name, tag_source FROM tags WHERE name = $1",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_tags_for_asset(
|
||||||
|
&self,
|
||||||
|
asset_id: &SystemId,
|
||||||
|
) -> Result<Vec<(Tag, AssetTag)>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, TagRow>(
|
||||||
|
"SELECT t.tag_id, t.name, t.tag_source
|
||||||
|
FROM tags t JOIN asset_tags at ON t.tag_id = at.tag_id
|
||||||
|
WHERE at.asset_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*asset_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
let at_rows = sqlx::query_as::<_, AssetTagRow>(
|
||||||
|
"SELECT asset_id, tag_id, tagged_by_user_id, confidence
|
||||||
|
FROM asset_tags WHERE asset_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*asset_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
let tags: Vec<Tag> = rows.into_iter().map(Into::into).collect();
|
||||||
|
let asset_tags: Vec<AssetTag> = at_rows.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
|
Ok(tags.into_iter().zip(asset_tags).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_tag(&self, tag: &Tag) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO tags (tag_id, name, tag_source)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (tag_id) DO UPDATE SET name = EXCLUDED.name, tag_source = EXCLUDED.tag_source",
|
||||||
|
)
|
||||||
|
.bind(*tag.tag_id.as_uuid())
|
||||||
|
.bind(&tag.name)
|
||||||
|
.bind(tag_source_to_str(&tag.tag_source))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_asset_tag(&self, asset_tag: &AssetTag) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO asset_tags (asset_id, tag_id, tagged_by_user_id, confidence)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (asset_id, tag_id) DO UPDATE SET
|
||||||
|
tagged_by_user_id = EXCLUDED.tagged_by_user_id,
|
||||||
|
confidence = EXCLUDED.confidence",
|
||||||
|
)
|
||||||
|
.bind(*asset_tag.asset_id.as_uuid())
|
||||||
|
.bind(*asset_tag.tag_id.as_uuid())
|
||||||
|
.bind(asset_tag.tagged_by_user_id.as_ref().map(|id| *id.as_uuid()))
|
||||||
|
.bind(asset_tag.confidence)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_asset_tag(
|
||||||
|
&self,
|
||||||
|
asset_id: &SystemId,
|
||||||
|
tag_id: &SystemId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM asset_tags WHERE asset_id = $1 AND tag_id = $2")
|
||||||
|
.bind(*asset_id.as_uuid())
|
||||||
|
.bind(*tag_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{PipelineStep, ProcessingPipeline},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::PipelineRepository,
|
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PipelineRow {
|
|
||||||
pipeline_id: Uuid,
|
|
||||||
trigger_event: String,
|
|
||||||
steps: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct StepJson {
|
|
||||||
plugin_id: Uuid,
|
|
||||||
step_order: u32,
|
|
||||||
configuration: serde_json::Map<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn steps_from_json(v: serde_json::Value) -> Vec<PipelineStep> {
|
|
||||||
let arr: Vec<StepJson> = serde_json::from_value(v).unwrap_or_default();
|
|
||||||
arr.into_iter()
|
|
||||||
.map(|s| {
|
|
||||||
let mut config = StructuredData::new();
|
|
||||||
for (k, val) in s.configuration {
|
|
||||||
config.insert(k, MetadataValue::from(val));
|
|
||||||
}
|
|
||||||
PipelineStep {
|
|
||||||
plugin_id: SystemId::from_uuid(s.plugin_id),
|
|
||||||
step_order: s.step_order,
|
|
||||||
configuration: config,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn steps_to_json(steps: &[PipelineStep]) -> serde_json::Value {
|
|
||||||
let arr: Vec<StepJson> = steps
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
let config: serde_json::Map<String, serde_json::Value> = s
|
|
||||||
.configuration
|
|
||||||
.inner()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), serde_json::Value::from(v)))
|
|
||||||
.collect();
|
|
||||||
StepJson {
|
|
||||||
plugin_id: *s.plugin_id.as_uuid(),
|
|
||||||
step_order: s.step_order,
|
|
||||||
configuration: config,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PipelineRow> for ProcessingPipeline {
|
|
||||||
fn from(r: PipelineRow) -> Self {
|
|
||||||
Self {
|
|
||||||
pipeline_id: SystemId::from_uuid(r.pipeline_id),
|
|
||||||
trigger_event: r.trigger_event,
|
|
||||||
steps: steps_from_json(r.steps),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresPipelineRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresPipelineRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl PipelineRepository for PostgresPipelineRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, PipelineRow>(
|
|
||||||
"SELECT pipeline_id, trigger_event, steps
|
|
||||||
FROM processing_pipelines WHERE pipeline_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_trigger(&self, event: &str) -> Result<Vec<ProcessingPipeline>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, PipelineRow>(
|
|
||||||
"SELECT pipeline_id, trigger_event, steps
|
|
||||||
FROM processing_pipelines WHERE trigger_event = $1",
|
|
||||||
)
|
|
||||||
.bind(event)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (pipeline_id) DO UPDATE SET
|
|
||||||
trigger_event = EXCLUDED.trigger_event,
|
|
||||||
steps = EXCLUDED.steps",
|
|
||||||
)
|
|
||||||
.bind(*pipeline.pipeline_id.as_uuid())
|
|
||||||
.bind(&pipeline.trigger_event)
|
|
||||||
.bind(steps_to_json(&pipeline.steps))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{Plugin, PluginType},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::PluginRepository,
|
|
||||||
value_objects::{MetadataValue, StructuredData, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct PluginRow {
|
|
||||||
plugin_id: Uuid,
|
|
||||||
name: String,
|
|
||||||
plugin_type: String,
|
|
||||||
is_enabled: bool,
|
|
||||||
configuration: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plugin_type_from_str(s: &str) -> PluginType {
|
|
||||||
match s {
|
|
||||||
"media_processor" => PluginType::MediaProcessor,
|
|
||||||
"scheduled_task" => PluginType::ScheduledTask,
|
|
||||||
"sidecar_writer" => PluginType::SidecarWriter,
|
|
||||||
_ => PluginType::MediaProcessor,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plugin_type_to_str(t: &PluginType) -> &'static str {
|
|
||||||
match t {
|
|
||||||
PluginType::MediaProcessor => "media_processor",
|
|
||||||
PluginType::ScheduledTask => "scheduled_task",
|
|
||||||
PluginType::SidecarWriter => "sidecar_writer",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn structured_from_json(v: serde_json::Value) -> StructuredData {
|
|
||||||
if let serde_json::Value::Object(map) = v {
|
|
||||||
let mut sd = StructuredData::new();
|
|
||||||
for (k, val) in map {
|
|
||||||
sd.insert(k, MetadataValue::from(val));
|
|
||||||
}
|
|
||||||
sd
|
|
||||||
} else {
|
|
||||||
StructuredData::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn structured_to_json(sd: &StructuredData) -> serde_json::Value {
|
|
||||||
let map: serde_json::Map<String, serde_json::Value> = sd
|
|
||||||
.inner()
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| (k.clone(), serde_json::Value::from(v)))
|
|
||||||
.collect();
|
|
||||||
serde_json::Value::Object(map)
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<PluginRow> for Plugin {
|
|
||||||
fn from(r: PluginRow) -> Self {
|
|
||||||
Self {
|
|
||||||
plugin_id: SystemId::from_uuid(r.plugin_id),
|
|
||||||
name: r.name,
|
|
||||||
plugin_type: plugin_type_from_str(&r.plugin_type),
|
|
||||||
is_enabled: r.is_enabled,
|
|
||||||
configuration: structured_from_json(r.configuration),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresPluginRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresPluginRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl PluginRepository for PostgresPluginRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, PluginRow>(
|
|
||||||
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
|
||||||
FROM plugins WHERE plugin_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_enabled(&self) -> Result<Vec<Plugin>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, PluginRow>(
|
|
||||||
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
|
||||||
FROM plugins WHERE is_enabled = true",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO plugins (plugin_id, name, plugin_type, is_enabled, configuration)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (plugin_id) DO UPDATE SET
|
|
||||||
name = EXCLUDED.name,
|
|
||||||
plugin_type = EXCLUDED.plugin_type,
|
|
||||||
is_enabled = EXCLUDED.is_enabled,
|
|
||||||
configuration = EXCLUDED.configuration",
|
|
||||||
)
|
|
||||||
.bind(*plugin.plugin_id.as_uuid())
|
|
||||||
.bind(&plugin.name)
|
|
||||||
.bind(plugin_type_to_str(&plugin.plugin_type))
|
|
||||||
.bind(plugin.is_enabled)
|
|
||||||
.bind(structured_to_json(&plugin.configuration))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
597
crates/adapters/postgres/src/processing/mod.rs
Normal file
597
crates/adapters/postgres/src/processing/mod.rs
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
entities::{
|
||||||
|
BatchStatus, Job, JobBatch, JobStatus, JobType, PipelineStep, Plugin, PluginType,
|
||||||
|
ProcessingPipeline,
|
||||||
|
},
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{JobBatchRepository, JobRepository, PipelineRepository, PluginRepository},
|
||||||
|
value_objects::{DateTimeStamp, MetadataValue, StructuredData, SystemId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Job
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct JobRow {
|
||||||
|
job_id: Uuid,
|
||||||
|
job_type: String,
|
||||||
|
target_asset_id: Option<Uuid>,
|
||||||
|
batch_id: Option<Uuid>,
|
||||||
|
status: String,
|
||||||
|
priority: i32,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
result_data: Option<serde_json::Value>,
|
||||||
|
retry_count: i32,
|
||||||
|
max_retries: i32,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
started_at: Option<DateTime<Utc>>,
|
||||||
|
completed_at: Option<DateTime<Utc>>,
|
||||||
|
error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_type_from_str(s: &str) -> JobType {
|
||||||
|
match s {
|
||||||
|
"scan_directory" => JobType::ScanDirectory,
|
||||||
|
"extract_metadata" => JobType::ExtractMetadata,
|
||||||
|
"generate_derivative" => JobType::GenerateDerivative,
|
||||||
|
"sync_sidecar" => JobType::SyncSidecar,
|
||||||
|
"detect_duplicates" => JobType::DetectDuplicates,
|
||||||
|
other => JobType::Custom(other.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_type_to_str(t: &JobType) -> String {
|
||||||
|
match t {
|
||||||
|
JobType::ScanDirectory => "scan_directory".to_string(),
|
||||||
|
JobType::ExtractMetadata => "extract_metadata".to_string(),
|
||||||
|
JobType::GenerateDerivative => "generate_derivative".to_string(),
|
||||||
|
JobType::SyncSidecar => "sync_sidecar".to_string(),
|
||||||
|
JobType::DetectDuplicates => "detect_duplicates".to_string(),
|
||||||
|
JobType::Custom(s) => s.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_status_from_str(s: &str) -> JobStatus {
|
||||||
|
match s {
|
||||||
|
"queued" => JobStatus::Queued,
|
||||||
|
"processing" => JobStatus::Processing,
|
||||||
|
"completed" => JobStatus::Completed,
|
||||||
|
"failed" => JobStatus::Failed,
|
||||||
|
"cancelled" => JobStatus::Cancelled,
|
||||||
|
_ => JobStatus::Queued,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn job_status_to_str(s: &JobStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
JobStatus::Queued => "queued",
|
||||||
|
JobStatus::Processing => "processing",
|
||||||
|
JobStatus::Completed => "completed",
|
||||||
|
JobStatus::Failed => "failed",
|
||||||
|
JobStatus::Cancelled => "cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn structured_from_json(v: serde_json::Value) -> StructuredData {
|
||||||
|
if let serde_json::Value::Object(map) = v {
|
||||||
|
let mut sd = StructuredData::new();
|
||||||
|
for (k, val) in map {
|
||||||
|
sd.insert(k, MetadataValue::from(val));
|
||||||
|
}
|
||||||
|
sd
|
||||||
|
} else {
|
||||||
|
StructuredData::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn structured_to_json(sd: &StructuredData) -> serde_json::Value {
|
||||||
|
let map: serde_json::Map<String, serde_json::Value> = sd
|
||||||
|
.inner()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), serde_json::Value::from(v)))
|
||||||
|
.collect();
|
||||||
|
serde_json::Value::Object(map)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<JobRow> for Job {
|
||||||
|
fn from(r: JobRow) -> Self {
|
||||||
|
Self {
|
||||||
|
job_id: SystemId::from_uuid(r.job_id),
|
||||||
|
job_type: job_type_from_str(&r.job_type),
|
||||||
|
target_asset_id: r.target_asset_id.map(SystemId::from_uuid),
|
||||||
|
batch_id: r.batch_id.map(SystemId::from_uuid),
|
||||||
|
status: job_status_from_str(&r.status),
|
||||||
|
priority: r.priority as u32,
|
||||||
|
payload: structured_from_json(r.payload),
|
||||||
|
result_data: r.result_data.map(structured_from_json),
|
||||||
|
retry_count: r.retry_count as u32,
|
||||||
|
max_retries: r.max_retries as u32,
|
||||||
|
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||||
|
started_at: r.started_at.map(DateTimeStamp::from_datetime),
|
||||||
|
completed_at: r.completed_at.map(DateTimeStamp::from_datetime),
|
||||||
|
error_message: r.error_message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresJobRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobRepository for PostgresJobRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Job>, DomainError> {
|
||||||
|
let row = 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 job_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_next_queued(&self) -> Result<Option<Job>, DomainError> {
|
||||||
|
let row = 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 = 'queued'
|
||||||
|
ORDER BY priority DESC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED",
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn claim_next(&self) -> Result<Option<Job>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, JobRow>(
|
||||||
|
"UPDATE jobs SET status = 'processing', started_at = NOW()
|
||||||
|
WHERE job_id = (
|
||||||
|
SELECT job_id FROM jobs
|
||||||
|
WHERE status = 'queued'
|
||||||
|
ORDER BY priority DESC, created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||||
|
payload, result_data, retry_count, max_retries, created_at,
|
||||||
|
started_at, completed_at, error_message",
|
||||||
|
)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all(
|
||||||
|
&self,
|
||||||
|
status: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Job>, DomainError> {
|
||||||
|
let rows = match status {
|
||||||
|
Some(s) => sqlx::query_as::<_, JobRow>(
|
||||||
|
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||||
|
payload, result_data, retry_count, max_retries, created_at,
|
||||||
|
started_at, completed_at, error_message
|
||||||
|
FROM jobs WHERE status = $1
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3",
|
||||||
|
)
|
||||||
|
.bind(s)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?,
|
||||||
|
None => sqlx::query_as::<_, JobRow>(
|
||||||
|
"SELECT job_id, job_type, target_asset_id, batch_id, status, priority,
|
||||||
|
payload, result_data, retry_count, max_retries, created_at,
|
||||||
|
started_at, completed_at, error_message
|
||||||
|
FROM jobs
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2",
|
||||||
|
)
|
||||||
|
.bind(limit as i64)
|
||||||
|
.bind(offset as i64)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?,
|
||||||
|
};
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(&self, status: Option<&str>) -> Result<u64, DomainError> {
|
||||||
|
let count: (i64,) = match status {
|
||||||
|
Some(s) => sqlx::query_as("SELECT COUNT(*) FROM jobs WHERE status = $1")
|
||||||
|
.bind(s)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?,
|
||||||
|
None => sqlx::query_as("SELECT COUNT(*) FROM jobs")
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?,
|
||||||
|
};
|
||||||
|
Ok(count.0 as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_batch(&self, batch_id: &SystemId) -> Result<Vec<Job>, DomainError> {
|
||||||
|
let rows = 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 batch_id = $1
|
||||||
|
ORDER BY created_at ASC",
|
||||||
|
)
|
||||||
|
.bind(*batch_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, job: &Job) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO jobs (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)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||||
|
ON CONFLICT (job_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
priority = EXCLUDED.priority,
|
||||||
|
payload = EXCLUDED.payload,
|
||||||
|
result_data = EXCLUDED.result_data,
|
||||||
|
retry_count = EXCLUDED.retry_count,
|
||||||
|
started_at = EXCLUDED.started_at,
|
||||||
|
completed_at = EXCLUDED.completed_at,
|
||||||
|
error_message = EXCLUDED.error_message",
|
||||||
|
)
|
||||||
|
.bind(*job.job_id.as_uuid())
|
||||||
|
.bind(job_type_to_str(&job.job_type))
|
||||||
|
.bind(job.target_asset_id.as_ref().map(|id| *id.as_uuid()))
|
||||||
|
.bind(job.batch_id.as_ref().map(|id| *id.as_uuid()))
|
||||||
|
.bind(job_status_to_str(&job.status))
|
||||||
|
.bind(job.priority as i32)
|
||||||
|
.bind(structured_to_json(&job.payload))
|
||||||
|
.bind(job.result_data.as_ref().map(structured_to_json))
|
||||||
|
.bind(job.retry_count as i32)
|
||||||
|
.bind(job.max_retries as i32)
|
||||||
|
.bind(job.created_at.as_datetime())
|
||||||
|
.bind(job.started_at.as_ref().map(|d| d.as_datetime()))
|
||||||
|
.bind(job.completed_at.as_ref().map(|d| d.as_datetime()))
|
||||||
|
.bind(&job.error_message)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// JobBatch
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct BatchRow {
|
||||||
|
batch_id: Uuid,
|
||||||
|
batch_type: String,
|
||||||
|
total_jobs: i32,
|
||||||
|
completed_count: i32,
|
||||||
|
failed_count: i32,
|
||||||
|
status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_status_from_str(s: &str) -> BatchStatus {
|
||||||
|
match s {
|
||||||
|
"in_progress" => BatchStatus::InProgress,
|
||||||
|
"completed_with_errors" => BatchStatus::CompletedWithErrors,
|
||||||
|
"completed" => BatchStatus::Completed,
|
||||||
|
"cancelled" => BatchStatus::Cancelled,
|
||||||
|
_ => BatchStatus::InProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn batch_status_to_str(s: &BatchStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
BatchStatus::InProgress => "in_progress",
|
||||||
|
BatchStatus::CompletedWithErrors => "completed_with_errors",
|
||||||
|
BatchStatus::Completed => "completed",
|
||||||
|
BatchStatus::Cancelled => "cancelled",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<BatchRow> for JobBatch {
|
||||||
|
fn from(r: BatchRow) -> Self {
|
||||||
|
Self {
|
||||||
|
batch_id: SystemId::from_uuid(r.batch_id),
|
||||||
|
batch_type: r.batch_type,
|
||||||
|
total_jobs: r.total_jobs as u32,
|
||||||
|
completed_count: r.completed_count as u32,
|
||||||
|
failed_count: r.failed_count as u32,
|
||||||
|
status: batch_status_from_str(&r.status),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresJobBatchRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobBatchRepository for PostgresJobBatchRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<JobBatch>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, BatchRow>(
|
||||||
|
"SELECT batch_id, batch_type, total_jobs, completed_count, failed_count, status
|
||||||
|
FROM job_batches WHERE batch_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, batch: &JobBatch) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO job_batches (batch_id, batch_type, total_jobs, completed_count, failed_count, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (batch_id) DO UPDATE SET
|
||||||
|
total_jobs = EXCLUDED.total_jobs,
|
||||||
|
completed_count = EXCLUDED.completed_count,
|
||||||
|
failed_count = EXCLUDED.failed_count,
|
||||||
|
status = EXCLUDED.status",
|
||||||
|
)
|
||||||
|
.bind(*batch.batch_id.as_uuid())
|
||||||
|
.bind(&batch.batch_type)
|
||||||
|
.bind(batch.total_jobs as i32)
|
||||||
|
.bind(batch.completed_count as i32)
|
||||||
|
.bind(batch.failed_count as i32)
|
||||||
|
.bind(batch_status_to_str(&batch.status))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Plugin
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PluginRow {
|
||||||
|
plugin_id: Uuid,
|
||||||
|
name: String,
|
||||||
|
plugin_type: String,
|
||||||
|
is_enabled: bool,
|
||||||
|
configuration: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_type_from_str(s: &str) -> PluginType {
|
||||||
|
match s {
|
||||||
|
"media_processor" => PluginType::MediaProcessor,
|
||||||
|
"scheduled_task" => PluginType::ScheduledTask,
|
||||||
|
"sidecar_writer" => PluginType::SidecarWriter,
|
||||||
|
_ => PluginType::MediaProcessor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_type_to_str(t: &PluginType) -> &'static str {
|
||||||
|
match t {
|
||||||
|
PluginType::MediaProcessor => "media_processor",
|
||||||
|
PluginType::ScheduledTask => "scheduled_task",
|
||||||
|
PluginType::SidecarWriter => "sidecar_writer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PluginRow> for Plugin {
|
||||||
|
fn from(r: PluginRow) -> Self {
|
||||||
|
Self {
|
||||||
|
plugin_id: SystemId::from_uuid(r.plugin_id),
|
||||||
|
name: r.name,
|
||||||
|
plugin_type: plugin_type_from_str(&r.plugin_type),
|
||||||
|
is_enabled: r.is_enabled,
|
||||||
|
configuration: structured_from_json(r.configuration),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresPluginRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PluginRepository for PostgresPluginRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Plugin>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, PluginRow>(
|
||||||
|
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
||||||
|
FROM plugins WHERE plugin_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let rows = sqlx::query_as::<_, PluginRow>(
|
||||||
|
"SELECT plugin_id, name, plugin_type, is_enabled, configuration
|
||||||
|
FROM plugins WHERE is_enabled = true",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, plugin: &Plugin) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO plugins (plugin_id, name, plugin_type, is_enabled, configuration)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (plugin_id) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
plugin_type = EXCLUDED.plugin_type,
|
||||||
|
is_enabled = EXCLUDED.is_enabled,
|
||||||
|
configuration = EXCLUDED.configuration",
|
||||||
|
)
|
||||||
|
.bind(*plugin.plugin_id.as_uuid())
|
||||||
|
.bind(&plugin.name)
|
||||||
|
.bind(plugin_type_to_str(&plugin.plugin_type))
|
||||||
|
.bind(plugin.is_enabled)
|
||||||
|
.bind(structured_to_json(&plugin.configuration))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Pipeline
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct PipelineRow {
|
||||||
|
pipeline_id: Uuid,
|
||||||
|
trigger_event: String,
|
||||||
|
steps: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct StepJson {
|
||||||
|
plugin_id: Uuid,
|
||||||
|
step_order: u32,
|
||||||
|
configuration: serde_json::Map<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn steps_from_json(v: serde_json::Value) -> Vec<PipelineStep> {
|
||||||
|
let arr: Vec<StepJson> = serde_json::from_value(v).unwrap_or_default();
|
||||||
|
arr.into_iter()
|
||||||
|
.map(|s| {
|
||||||
|
let mut config = StructuredData::new();
|
||||||
|
for (k, val) in s.configuration {
|
||||||
|
config.insert(k, MetadataValue::from(val));
|
||||||
|
}
|
||||||
|
PipelineStep {
|
||||||
|
plugin_id: SystemId::from_uuid(s.plugin_id),
|
||||||
|
step_order: s.step_order,
|
||||||
|
configuration: config,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn steps_to_json(steps: &[PipelineStep]) -> serde_json::Value {
|
||||||
|
let arr: Vec<StepJson> = steps
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
let config: serde_json::Map<String, serde_json::Value> = s
|
||||||
|
.configuration
|
||||||
|
.inner()
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.clone(), serde_json::Value::from(v)))
|
||||||
|
.collect();
|
||||||
|
StepJson {
|
||||||
|
plugin_id: *s.plugin_id.as_uuid(),
|
||||||
|
step_order: s.step_order,
|
||||||
|
configuration: config,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
serde_json::to_value(arr).unwrap_or(serde_json::Value::Array(vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PipelineRow> for ProcessingPipeline {
|
||||||
|
fn from(r: PipelineRow) -> Self {
|
||||||
|
Self {
|
||||||
|
pipeline_id: SystemId::from_uuid(r.pipeline_id),
|
||||||
|
trigger_event: r.trigger_event,
|
||||||
|
steps: steps_from_json(r.steps),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresPipelineRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PipelineRepository for PostgresPipelineRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<ProcessingPipeline>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, PipelineRow>(
|
||||||
|
"SELECT pipeline_id, trigger_event, steps
|
||||||
|
FROM processing_pipelines WHERE pipeline_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let rows = sqlx::query_as::<_, PipelineRow>(
|
||||||
|
"SELECT pipeline_id, trigger_event, steps
|
||||||
|
FROM processing_pipelines WHERE trigger_event = $1",
|
||||||
|
)
|
||||||
|
.bind(event)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, pipeline: &ProcessingPipeline) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO processing_pipelines (pipeline_id, trigger_event, steps)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (pipeline_id) DO UPDATE SET
|
||||||
|
trigger_event = EXCLUDED.trigger_event,
|
||||||
|
steps = EXCLUDED.steps",
|
||||||
|
)
|
||||||
|
.bind(*pipeline.pipeline_id.as_uuid())
|
||||||
|
.bind(&pipeline.trigger_event)
|
||||||
|
.bind(steps_to_json(&pipeline.steps))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
entities::{QuotaDefinition, QuotaRule, TimePeriod, UsageLedgerEntry, UsageType},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::{QuotaRepository, UsageLedgerRepository},
|
|
||||||
value_objects::{DateTimeStamp, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
// --- Enum mappings ---
|
|
||||||
|
|
||||||
fn usage_type_from_str(s: &str) -> UsageType {
|
|
||||||
match s {
|
|
||||||
"storage_bytes" => UsageType::StorageBytes,
|
|
||||||
"process_jobs" => UsageType::ProcessJobs,
|
|
||||||
"api_calls" => UsageType::ApiCalls,
|
|
||||||
"indexing_size" => UsageType::IndexingSize,
|
|
||||||
_ => UsageType::StorageBytes,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn usage_type_to_str(t: &UsageType) -> &'static str {
|
|
||||||
match t {
|
|
||||||
UsageType::StorageBytes => "storage_bytes",
|
|
||||||
UsageType::ProcessJobs => "process_jobs",
|
|
||||||
UsageType::ApiCalls => "api_calls",
|
|
||||||
UsageType::IndexingSize => "indexing_size",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time_period_from_str(s: &str) -> TimePeriod {
|
|
||||||
match s {
|
|
||||||
"daily" => TimePeriod::Daily,
|
|
||||||
"monthly" => TimePeriod::Monthly,
|
|
||||||
"lifetime" => TimePeriod::Lifetime,
|
|
||||||
_ => TimePeriod::Lifetime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn time_period_to_str(p: &TimePeriod) -> &'static str {
|
|
||||||
match p {
|
|
||||||
TimePeriod::Daily => "daily",
|
|
||||||
TimePeriod::Monthly => "monthly",
|
|
||||||
TimePeriod::Lifetime => "lifetime",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Row structs ---
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct QuotaDefRow {
|
|
||||||
quota_id: Uuid,
|
|
||||||
owner_scope: Uuid,
|
|
||||||
is_enforced: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
#[allow(dead_code)]
|
|
||||||
struct QuotaRuleRow {
|
|
||||||
rule_id: Uuid,
|
|
||||||
quota_id: Uuid,
|
|
||||||
dimension: String,
|
|
||||||
limit_value: i64,
|
|
||||||
time_period: String,
|
|
||||||
is_unlimited: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct UsageLedgerRow {
|
|
||||||
entry_id: Uuid,
|
|
||||||
user_id: Uuid,
|
|
||||||
usage_type: String,
|
|
||||||
consumed_amount: i64,
|
|
||||||
timestamp: DateTime<Utc>,
|
|
||||||
context: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct SumRow {
|
|
||||||
total: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<QuotaRuleRow> for QuotaRule {
|
|
||||||
fn from(r: QuotaRuleRow) -> Self {
|
|
||||||
Self {
|
|
||||||
rule_id: SystemId::from_uuid(r.rule_id),
|
|
||||||
dimension: usage_type_from_str(&r.dimension),
|
|
||||||
limit_value: r.limit_value as u64,
|
|
||||||
time_period: time_period_from_str(&r.time_period),
|
|
||||||
is_unlimited: r.is_unlimited,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<UsageLedgerRow> for UsageLedgerEntry {
|
|
||||||
fn from(r: UsageLedgerRow) -> Self {
|
|
||||||
Self {
|
|
||||||
entry_id: SystemId::from_uuid(r.entry_id),
|
|
||||||
user_id: SystemId::from_uuid(r.user_id),
|
|
||||||
usage_type: usage_type_from_str(&r.usage_type),
|
|
||||||
consumed_amount: r.consumed_amount as u64,
|
|
||||||
timestamp: DateTimeStamp::from_datetime(r.timestamp),
|
|
||||||
context: r.context,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PostgresQuotaRepository ---
|
|
||||||
|
|
||||||
pub struct PostgresQuotaRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresQuotaRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl QuotaRepository for PostgresQuotaRepository {
|
|
||||||
async fn find_by_owner(
|
|
||||||
&self,
|
|
||||||
owner_id: &SystemId,
|
|
||||||
) -> Result<Option<QuotaDefinition>, DomainError> {
|
|
||||||
let def_row = sqlx::query_as::<_, QuotaDefRow>(
|
|
||||||
"SELECT quota_id, owner_scope, is_enforced FROM quota_definitions WHERE owner_scope = $1",
|
|
||||||
)
|
|
||||||
.bind(*owner_id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let Some(def) = def_row else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let rule_rows = sqlx::query_as::<_, QuotaRuleRow>(
|
|
||||||
"SELECT rule_id, quota_id, dimension, limit_value, time_period, is_unlimited
|
|
||||||
FROM quota_rules WHERE quota_id = $1",
|
|
||||||
)
|
|
||||||
.bind(def.quota_id)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Some(QuotaDefinition {
|
|
||||||
quota_id: SystemId::from_uuid(def.quota_id),
|
|
||||||
owner_scope: SystemId::from_uuid(def.owner_scope),
|
|
||||||
is_enforced: def.is_enforced,
|
|
||||||
rules: rule_rows.into_iter().map(Into::into).collect(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO quota_definitions (quota_id, owner_scope, is_enforced)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (quota_id) DO UPDATE SET
|
|
||||||
owner_scope = EXCLUDED.owner_scope,
|
|
||||||
is_enforced = EXCLUDED.is_enforced",
|
|
||||||
)
|
|
||||||
.bind(*quota.quota_id.as_uuid())
|
|
||||||
.bind(*quota.owner_scope.as_uuid())
|
|
||||||
.bind(quota.is_enforced)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
// Delete old rules then re-insert
|
|
||||||
sqlx::query("DELETE FROM quota_rules WHERE quota_id = $1")
|
|
||||||
.bind(*quota.quota_id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
for rule in "a.rules {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO quota_rules (rule_id, quota_id, dimension, limit_value, time_period, is_unlimited)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(*rule.rule_id.as_uuid())
|
|
||||||
.bind(*quota.quota_id.as_uuid())
|
|
||||||
.bind(usage_type_to_str(&rule.dimension))
|
|
||||||
.bind(rule.limit_value as i64)
|
|
||||||
.bind(time_period_to_str(&rule.time_period))
|
|
||||||
.bind(rule.is_unlimited)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
// Rules cascade-delete
|
|
||||||
sqlx::query("DELETE FROM quota_definitions WHERE quota_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- PostgresUsageLedgerRepository ---
|
|
||||||
|
|
||||||
pub struct PostgresUsageLedgerRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresUsageLedgerRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UsageLedgerRepository for PostgresUsageLedgerRepository {
|
|
||||||
async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO usage_ledger (entry_id, user_id, usage_type, consumed_amount, timestamp, context)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
|
||||||
)
|
|
||||||
.bind(*entry.entry_id.as_uuid())
|
|
||||||
.bind(*entry.user_id.as_uuid())
|
|
||||||
.bind(usage_type_to_str(&entry.usage_type))
|
|
||||||
.bind(entry.consumed_amount as i64)
|
|
||||||
.bind(entry.timestamp.as_datetime())
|
|
||||||
.bind(&entry.context)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn sum_usage(
|
|
||||||
&self,
|
|
||||||
user_id: &SystemId,
|
|
||||||
usage_type: UsageType,
|
|
||||||
since: Option<DateTimeStamp>,
|
|
||||||
) -> Result<u64, DomainError> {
|
|
||||||
let since_dt: Option<DateTime<Utc>> = since.map(|s| *s.as_datetime());
|
|
||||||
let row = sqlx::query_as::<_, SumRow>(
|
|
||||||
"SELECT COALESCE(SUM(consumed_amount), 0) as total
|
|
||||||
FROM usage_ledger
|
|
||||||
WHERE user_id = $1 AND usage_type = $2 AND ($3::timestamptz IS NULL OR timestamp >= $3)",
|
|
||||||
)
|
|
||||||
.bind(*user_id.as_uuid())
|
|
||||||
.bind(usage_type_to_str(&usage_type))
|
|
||||||
.bind(since_dt)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.total as u64)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
use crate::db::PgPool;
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use domain::{
|
use domain::{
|
||||||
entities::{
|
entities::{
|
||||||
InviteCode, LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareTarget, ShareableType,
|
InviteCode, LinkAccessLevel, ScopeType, ShareLink, ShareScope, ShareTarget, ShareableType,
|
||||||
TargetType,
|
TargetType, VisibilityFilter,
|
||||||
},
|
},
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::ShareRepository,
|
ports::{ShareRepository, VisibilityFilterRepository},
|
||||||
value_objects::{DateTimeStamp, SystemId},
|
value_objects::{DateTimeStamp, SystemId},
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// --- String constants for DB enum mapping ---
|
// ──────────────────────────────────────────────
|
||||||
|
// Share
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
const SCOPE_PRIVATE: &str = "private";
|
const SCOPE_PRIVATE: &str = "private";
|
||||||
const SCOPE_USER: &str = "user";
|
const SCOPE_USER: &str = "user";
|
||||||
@@ -31,8 +33,6 @@ const TARGET_GROUP: &str = "group";
|
|||||||
const ACCESS_VIEW_ONLY: &str = "view_only";
|
const ACCESS_VIEW_ONLY: &str = "view_only";
|
||||||
const ACCESS_LIMITED_SEARCH: &str = "limited_search";
|
const ACCESS_LIMITED_SEARCH: &str = "limited_search";
|
||||||
|
|
||||||
// --- Row structs ---
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ShareScopeRow {
|
struct ShareScopeRow {
|
||||||
scope_id: Uuid,
|
scope_id: Uuid,
|
||||||
@@ -74,8 +74,6 @@ struct InviteCodeRow {
|
|||||||
assigned_role_id: Uuid,
|
assigned_role_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Enum conversions ---
|
|
||||||
|
|
||||||
fn scope_type_to_str(t: ScopeType) -> &'static str {
|
fn scope_type_to_str(t: ScopeType) -> &'static str {
|
||||||
match t {
|
match t {
|
||||||
ScopeType::Private => SCOPE_PRIVATE,
|
ScopeType::Private => SCOPE_PRIVATE,
|
||||||
@@ -148,8 +146,6 @@ fn access_level_from_str(s: &str) -> Result<LinkAccessLevel, DomainError> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Row → Domain conversions ---
|
|
||||||
|
|
||||||
impl TryFrom<ShareScopeRow> for ShareScope {
|
impl TryFrom<ShareScopeRow> for ShareScope {
|
||||||
type Error = DomainError;
|
type Error = DomainError;
|
||||||
|
|
||||||
@@ -211,17 +207,7 @@ impl TryFrom<InviteCodeRow> for InviteCode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Repository ---
|
pg_repo!(PostgresShareRepository);
|
||||||
|
|
||||||
pub struct PostgresShareRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresShareRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ShareRepository for PostgresShareRepository {
|
impl ShareRepository for PostgresShareRepository {
|
||||||
@@ -244,7 +230,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(scope.created_at.as_datetime())
|
.bind(scope.created_at.as_datetime())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +242,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*id.as_uuid())
|
.bind(*id.as_uuid())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
@@ -272,7 +258,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*resource_id.as_uuid())
|
.bind(*resource_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
@@ -282,7 +268,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*id.as_uuid())
|
.bind(*id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +288,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*target.role_id.as_uuid())
|
.bind(*target.role_id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +303,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*scope_id.as_uuid())
|
.bind(*scope_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
@@ -333,7 +319,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*user_id.as_uuid())
|
.bind(*user_id.as_uuid())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
@@ -361,7 +347,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(link.use_count as i32)
|
.bind(link.use_count as i32)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,7 +359,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(token)
|
.bind(token)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
@@ -398,7 +384,7 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*invite.assigned_role_id.as_uuid())
|
.bind(*invite.assigned_role_id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,8 +396,80 @@ impl ShareRepository for PostgresShareRepository {
|
|||||||
.bind(*id.as_uuid())
|
.bind(*id.as_uuid())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// VisibilityFilter
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct VisibilityFilterRow {
|
||||||
|
filter_id: Uuid,
|
||||||
|
scope_id: Uuid,
|
||||||
|
role_id: Uuid,
|
||||||
|
hidden_fields: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VisibilityFilterRow> for VisibilityFilter {
|
||||||
|
fn from(r: VisibilityFilterRow) -> Self {
|
||||||
|
Self {
|
||||||
|
filter_id: SystemId::from_uuid(r.filter_id),
|
||||||
|
scope_id: SystemId::from_uuid(r.scope_id),
|
||||||
|
role_id: SystemId::from_uuid(r.role_id),
|
||||||
|
hidden_fields: r.hidden_fields,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresVisibilityFilterRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
|
||||||
|
async fn find_by_scope_and_role(
|
||||||
|
&self,
|
||||||
|
scope_id: &SystemId,
|
||||||
|
role_id: &SystemId,
|
||||||
|
) -> Result<Option<VisibilityFilter>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, VisibilityFilterRow>(
|
||||||
|
"SELECT filter_id, scope_id, role_id, hidden_fields
|
||||||
|
FROM visibility_filters WHERE scope_id = $1 AND role_id = $2",
|
||||||
|
)
|
||||||
|
.bind(*scope_id.as_uuid())
|
||||||
|
.bind(*role_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO visibility_filters (filter_id, scope_id, role_id, hidden_fields)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (filter_id) DO UPDATE SET
|
||||||
|
hidden_fields = EXCLUDED.hidden_fields",
|
||||||
|
)
|
||||||
|
.bind(*filter.filter_id.as_uuid())
|
||||||
|
.bind(*filter.scope_id.as_uuid())
|
||||||
|
.bind(*filter.role_id.as_uuid())
|
||||||
|
.bind(&filter.hidden_fields)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM visibility_filters WHERE filter_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::db::PgPool;
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use domain::{
|
use domain::{
|
||||||
@@ -55,15 +55,7 @@ impl TryFrom<SidecarRow> for SidecarRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct PostgresSidecarRepository {
|
pg_repo!(PostgresSidecarRepository);
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresSidecarRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SidecarRepository for PostgresSidecarRepository {
|
impl SidecarRepository for PostgresSidecarRepository {
|
||||||
@@ -79,7 +71,7 @@ impl SidecarRepository for PostgresSidecarRepository {
|
|||||||
.bind(*asset_id.as_uuid())
|
.bind(*asset_id.as_uuid())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
row.map(TryInto::try_into).transpose()
|
||||||
}
|
}
|
||||||
@@ -93,7 +85,7 @@ impl SidecarRepository for PostgresSidecarRepository {
|
|||||||
.bind(sync_status_to_str(&status))
|
.bind(sync_status_to_str(&status))
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
|
|
||||||
rows.into_iter().map(TryInto::try_into).collect()
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
}
|
}
|
||||||
@@ -118,7 +110,7 @@ impl SidecarRepository for PostgresSidecarRepository {
|
|||||||
.bind(&record.error_message)
|
.bind(&record.error_message)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +119,7 @@ impl SidecarRepository for PostgresSidecarRepository {
|
|||||||
.bind(*asset_id.as_uuid())
|
.bind(*asset_id.as_uuid())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_pg()?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
627
crates/adapters/postgres/src/storage/mod.rs
Normal file
627
crates/adapters/postgres/src/storage/mod.rs
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
use crate::helpers::{MapDomainError, pg_repo};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use domain::{
|
||||||
|
entities::{
|
||||||
|
Asset, IngestSession, IngestStatus, LibraryPath, OwnershipPolicy, QuotaDefinition,
|
||||||
|
QuotaRule, StorageVolume, TimePeriod, UsageLedgerEntry, UsageType,
|
||||||
|
},
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{
|
||||||
|
IngestSessionRepository, IngestTransaction, LibraryPathRepository, QuotaRepository,
|
||||||
|
StorageVolumeRepository, UsageLedgerRepository,
|
||||||
|
},
|
||||||
|
value_objects::{Checksum, DateTimeStamp, SystemId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// StorageVolume
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct StorageVolumeRow {
|
||||||
|
volume_id: Uuid,
|
||||||
|
volume_name: String,
|
||||||
|
uri_prefix: String,
|
||||||
|
is_writable: bool,
|
||||||
|
available_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<StorageVolumeRow> for StorageVolume {
|
||||||
|
fn from(r: StorageVolumeRow) -> Self {
|
||||||
|
Self {
|
||||||
|
volume_id: SystemId::from_uuid(r.volume_id),
|
||||||
|
volume_name: r.volume_name,
|
||||||
|
uri_prefix: r.uri_prefix,
|
||||||
|
is_writable: r.is_writable,
|
||||||
|
available_bytes: r.available_bytes as u64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresStorageVolumeRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl StorageVolumeRepository for PostgresStorageVolumeRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<StorageVolume>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, StorageVolumeRow>(
|
||||||
|
"SELECT volume_id, volume_name, uri_prefix, is_writable, available_bytes
|
||||||
|
FROM storage_volumes WHERE volume_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_all(&self) -> Result<Vec<StorageVolume>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, StorageVolumeRow>(
|
||||||
|
"SELECT volume_id, volume_name, uri_prefix, is_writable, available_bytes
|
||||||
|
FROM storage_volumes",
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO storage_volumes (volume_id, volume_name, uri_prefix, is_writable, available_bytes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (volume_id) DO UPDATE SET
|
||||||
|
volume_name = EXCLUDED.volume_name,
|
||||||
|
uri_prefix = EXCLUDED.uri_prefix,
|
||||||
|
is_writable = EXCLUDED.is_writable,
|
||||||
|
available_bytes = EXCLUDED.available_bytes",
|
||||||
|
)
|
||||||
|
.bind(*volume.volume_id.as_uuid())
|
||||||
|
.bind(&volume.volume_name)
|
||||||
|
.bind(&volume.uri_prefix)
|
||||||
|
.bind(volume.is_writable)
|
||||||
|
.bind(volume.available_bytes as i64)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM storage_volumes WHERE volume_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// LibraryPath
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct LibraryPathRow {
|
||||||
|
path_id: Uuid,
|
||||||
|
volume_id: Uuid,
|
||||||
|
relative_path: String,
|
||||||
|
is_ingest_destination: bool,
|
||||||
|
ownership_policy: String,
|
||||||
|
designated_owner_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_from_str(s: &str) -> OwnershipPolicy {
|
||||||
|
match s {
|
||||||
|
"user_owned" => OwnershipPolicy::UserOwned,
|
||||||
|
"group_owned" => OwnershipPolicy::GroupOwned,
|
||||||
|
_ => OwnershipPolicy::Unassigned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn policy_to_str(p: &OwnershipPolicy) -> &'static str {
|
||||||
|
match p {
|
||||||
|
OwnershipPolicy::UserOwned => "user_owned",
|
||||||
|
OwnershipPolicy::GroupOwned => "group_owned",
|
||||||
|
OwnershipPolicy::Unassigned => "unassigned",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<LibraryPathRow> for LibraryPath {
|
||||||
|
fn from(r: LibraryPathRow) -> Self {
|
||||||
|
Self {
|
||||||
|
path_id: SystemId::from_uuid(r.path_id),
|
||||||
|
volume_id: SystemId::from_uuid(r.volume_id),
|
||||||
|
relative_path: r.relative_path,
|
||||||
|
is_ingest_destination: r.is_ingest_destination,
|
||||||
|
ownership_policy: policy_from_str(&r.ownership_policy),
|
||||||
|
designated_owner_id: r.designated_owner_id.map(SystemId::from_uuid),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresLibraryPathRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LibraryPathRepository for PostgresLibraryPathRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<LibraryPath>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, LibraryPathRow>(
|
||||||
|
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||||
|
FROM library_paths WHERE path_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let rows = sqlx::query_as::<_, LibraryPathRow>(
|
||||||
|
"SELECT path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id
|
||||||
|
FROM library_paths WHERE volume_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*volume_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_ingest_destinations(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> 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
|
||||||
|
WHERE is_ingest_destination = true AND (designated_owner_id = $1 OR designated_owner_id IS NULL)",
|
||||||
|
)
|
||||||
|
.bind(*owner_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, path: &LibraryPath) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO library_paths (path_id, volume_id, relative_path, is_ingest_destination, ownership_policy, designated_owner_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (path_id) DO UPDATE SET
|
||||||
|
volume_id = EXCLUDED.volume_id,
|
||||||
|
relative_path = EXCLUDED.relative_path,
|
||||||
|
is_ingest_destination = EXCLUDED.is_ingest_destination,
|
||||||
|
ownership_policy = EXCLUDED.ownership_policy,
|
||||||
|
designated_owner_id = EXCLUDED.designated_owner_id",
|
||||||
|
)
|
||||||
|
.bind(*path.path_id.as_uuid())
|
||||||
|
.bind(*path.volume_id.as_uuid())
|
||||||
|
.bind(&path.relative_path)
|
||||||
|
.bind(path.is_ingest_destination)
|
||||||
|
.bind(policy_to_str(&path.ownership_policy))
|
||||||
|
.bind(path.designated_owner_id.as_ref().map(|id| *id.as_uuid()))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
sqlx::query("DELETE FROM library_paths WHERE path_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// IngestSession
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct IngestSessionRow {
|
||||||
|
session_id: Uuid,
|
||||||
|
uploader_user_id: Uuid,
|
||||||
|
client_device_id: String,
|
||||||
|
original_filename: String,
|
||||||
|
client_checksum: String,
|
||||||
|
target_library_path_id: Uuid,
|
||||||
|
status: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ingest_status_from_str(s: &str) -> IngestStatus {
|
||||||
|
match s {
|
||||||
|
"uploading" => IngestStatus::Uploading,
|
||||||
|
"awaiting_processing" => IngestStatus::AwaitingProcessing,
|
||||||
|
"processing" => IngestStatus::Processing,
|
||||||
|
"completed" => IngestStatus::Completed,
|
||||||
|
"failed" => IngestStatus::Failed,
|
||||||
|
_ => IngestStatus::Uploading,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ingest_status_to_str(s: &IngestStatus) -> &'static str {
|
||||||
|
match s {
|
||||||
|
IngestStatus::Uploading => "uploading",
|
||||||
|
IngestStatus::AwaitingProcessing => "awaiting_processing",
|
||||||
|
IngestStatus::Processing => "processing",
|
||||||
|
IngestStatus::Completed => "completed",
|
||||||
|
IngestStatus::Failed => "failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<IngestSessionRow> for IngestSession {
|
||||||
|
type Error = DomainError;
|
||||||
|
fn try_from(r: IngestSessionRow) -> Result<Self, Self::Error> {
|
||||||
|
Ok(Self {
|
||||||
|
session_id: SystemId::from_uuid(r.session_id),
|
||||||
|
uploader_user_id: SystemId::from_uuid(r.uploader_user_id),
|
||||||
|
client_device_id: r.client_device_id,
|
||||||
|
original_filename: r.original_filename,
|
||||||
|
client_checksum: Checksum::new(r.client_checksum)?,
|
||||||
|
target_library_path_id: SystemId::from_uuid(r.target_library_path_id),
|
||||||
|
status: ingest_status_from_str(&r.status),
|
||||||
|
created_at: DateTimeStamp::from_datetime(r.created_at),
|
||||||
|
error_message: r.error_message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresIngestSessionRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IngestSessionRepository for PostgresIngestSessionRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<IngestSession>, DomainError> {
|
||||||
|
let row = sqlx::query_as::<_, IngestSessionRow>(
|
||||||
|
"SELECT session_id, uploader_user_id, client_device_id, original_filename,
|
||||||
|
client_checksum, target_library_path_id, status, created_at, error_message
|
||||||
|
FROM ingest_sessions WHERE session_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
row.map(TryInto::try_into).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_user(&self, user_id: &SystemId) -> Result<Vec<IngestSession>, DomainError> {
|
||||||
|
let rows = sqlx::query_as::<_, IngestSessionRow>(
|
||||||
|
"SELECT session_id, uploader_user_id, client_device_id, original_filename,
|
||||||
|
client_checksum, target_library_path_id, status, created_at, error_message
|
||||||
|
FROM ingest_sessions WHERE uploader_user_id = $1",
|
||||||
|
)
|
||||||
|
.bind(*user_id.as_uuid())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
rows.into_iter().map(TryInto::try_into).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, session: &IngestSession) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ingest_sessions (session_id, uploader_user_id, client_device_id, original_filename,
|
||||||
|
client_checksum, target_library_path_id, status, created_at, error_message)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (session_id) DO UPDATE SET
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
error_message = EXCLUDED.error_message",
|
||||||
|
)
|
||||||
|
.bind(*session.session_id.as_uuid())
|
||||||
|
.bind(*session.uploader_user_id.as_uuid())
|
||||||
|
.bind(&session.client_device_id)
|
||||||
|
.bind(&session.original_filename)
|
||||||
|
.bind(session.client_checksum.as_str())
|
||||||
|
.bind(*session.target_library_path_id.as_uuid())
|
||||||
|
.bind(ingest_status_to_str(&session.status))
|
||||||
|
.bind(session.created_at.as_datetime())
|
||||||
|
.bind(session.error_message.as_deref())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Quota + UsageLedger
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn usage_type_from_str(s: &str) -> UsageType {
|
||||||
|
match s {
|
||||||
|
"storage_bytes" => UsageType::StorageBytes,
|
||||||
|
"process_jobs" => UsageType::ProcessJobs,
|
||||||
|
"api_calls" => UsageType::ApiCalls,
|
||||||
|
"indexing_size" => UsageType::IndexingSize,
|
||||||
|
_ => UsageType::StorageBytes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage_type_to_str(t: &UsageType) -> &'static str {
|
||||||
|
match t {
|
||||||
|
UsageType::StorageBytes => "storage_bytes",
|
||||||
|
UsageType::ProcessJobs => "process_jobs",
|
||||||
|
UsageType::ApiCalls => "api_calls",
|
||||||
|
UsageType::IndexingSize => "indexing_size",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_period_from_str(s: &str) -> TimePeriod {
|
||||||
|
match s {
|
||||||
|
"daily" => TimePeriod::Daily,
|
||||||
|
"monthly" => TimePeriod::Monthly,
|
||||||
|
"lifetime" => TimePeriod::Lifetime,
|
||||||
|
_ => TimePeriod::Lifetime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_period_to_str(p: &TimePeriod) -> &'static str {
|
||||||
|
match p {
|
||||||
|
TimePeriod::Daily => "daily",
|
||||||
|
TimePeriod::Monthly => "monthly",
|
||||||
|
TimePeriod::Lifetime => "lifetime",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct QuotaDefRow {
|
||||||
|
quota_id: Uuid,
|
||||||
|
owner_scope: Uuid,
|
||||||
|
is_enforced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct QuotaRuleRow {
|
||||||
|
rule_id: Uuid,
|
||||||
|
quota_id: Uuid,
|
||||||
|
dimension: String,
|
||||||
|
limit_value: i64,
|
||||||
|
time_period: String,
|
||||||
|
is_unlimited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct UsageLedgerRow {
|
||||||
|
entry_id: Uuid,
|
||||||
|
user_id: Uuid,
|
||||||
|
usage_type: String,
|
||||||
|
consumed_amount: i64,
|
||||||
|
timestamp: DateTime<Utc>,
|
||||||
|
context: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct SumRow {
|
||||||
|
total: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<QuotaRuleRow> for QuotaRule {
|
||||||
|
fn from(r: QuotaRuleRow) -> Self {
|
||||||
|
Self {
|
||||||
|
rule_id: SystemId::from_uuid(r.rule_id),
|
||||||
|
dimension: usage_type_from_str(&r.dimension),
|
||||||
|
limit_value: r.limit_value as u64,
|
||||||
|
time_period: time_period_from_str(&r.time_period),
|
||||||
|
is_unlimited: r.is_unlimited,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<UsageLedgerRow> for UsageLedgerEntry {
|
||||||
|
fn from(r: UsageLedgerRow) -> Self {
|
||||||
|
Self {
|
||||||
|
entry_id: SystemId::from_uuid(r.entry_id),
|
||||||
|
user_id: SystemId::from_uuid(r.user_id),
|
||||||
|
usage_type: usage_type_from_str(&r.usage_type),
|
||||||
|
consumed_amount: r.consumed_amount as u64,
|
||||||
|
timestamp: DateTimeStamp::from_datetime(r.timestamp),
|
||||||
|
context: r.context,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresQuotaRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl QuotaRepository for PostgresQuotaRepository {
|
||||||
|
async fn find_by_owner(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Option<QuotaDefinition>, DomainError> {
|
||||||
|
let def_row = sqlx::query_as::<_, QuotaDefRow>(
|
||||||
|
"SELECT quota_id, owner_scope, is_enforced FROM quota_definitions WHERE owner_scope = $1",
|
||||||
|
)
|
||||||
|
.bind(*owner_id.as_uuid())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
let Some(def) = def_row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let rule_rows = sqlx::query_as::<_, QuotaRuleRow>(
|
||||||
|
"SELECT rule_id, quota_id, dimension, limit_value, time_period, is_unlimited
|
||||||
|
FROM quota_rules WHERE quota_id = $1",
|
||||||
|
)
|
||||||
|
.bind(def.quota_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(Some(QuotaDefinition {
|
||||||
|
quota_id: SystemId::from_uuid(def.quota_id),
|
||||||
|
owner_scope: SystemId::from_uuid(def.owner_scope),
|
||||||
|
is_enforced: def.is_enforced,
|
||||||
|
rules: rule_rows.into_iter().map(Into::into).collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, quota: &QuotaDefinition) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO quota_definitions (quota_id, owner_scope, is_enforced)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (quota_id) DO UPDATE SET
|
||||||
|
owner_scope = EXCLUDED.owner_scope,
|
||||||
|
is_enforced = EXCLUDED.is_enforced",
|
||||||
|
)
|
||||||
|
.bind(*quota.quota_id.as_uuid())
|
||||||
|
.bind(*quota.owner_scope.as_uuid())
|
||||||
|
.bind(quota.is_enforced)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
// Delete old rules then re-insert
|
||||||
|
sqlx::query("DELETE FROM quota_rules WHERE quota_id = $1")
|
||||||
|
.bind(*quota.quota_id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
for rule in "a.rules {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO quota_rules (rule_id, quota_id, dimension, limit_value, time_period, is_unlimited)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(*rule.rule_id.as_uuid())
|
||||||
|
.bind(*quota.quota_id.as_uuid())
|
||||||
|
.bind(usage_type_to_str(&rule.dimension))
|
||||||
|
.bind(rule.limit_value as i64)
|
||||||
|
.bind(time_period_to_str(&rule.time_period))
|
||||||
|
.bind(rule.is_unlimited)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
// Rules cascade-delete
|
||||||
|
sqlx::query("DELETE FROM quota_definitions WHERE quota_id = $1")
|
||||||
|
.bind(*id.as_uuid())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pg_repo!(PostgresUsageLedgerRepository);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UsageLedgerRepository for PostgresUsageLedgerRepository {
|
||||||
|
async fn record(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO usage_ledger (entry_id, user_id, usage_type, consumed_amount, timestamp, context)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||||
|
)
|
||||||
|
.bind(*entry.entry_id.as_uuid())
|
||||||
|
.bind(*entry.user_id.as_uuid())
|
||||||
|
.bind(usage_type_to_str(&entry.usage_type))
|
||||||
|
.bind(entry.consumed_amount as i64)
|
||||||
|
.bind(entry.timestamp.as_datetime())
|
||||||
|
.bind(&entry.context)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sum_usage(
|
||||||
|
&self,
|
||||||
|
user_id: &SystemId,
|
||||||
|
usage_type: UsageType,
|
||||||
|
since: Option<DateTimeStamp>,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
let since_dt: Option<DateTime<Utc>> = since.map(|s| *s.as_datetime());
|
||||||
|
let row = sqlx::query_as::<_, SumRow>(
|
||||||
|
"SELECT COALESCE(SUM(consumed_amount), 0) as total
|
||||||
|
FROM usage_ledger
|
||||||
|
WHERE user_id = $1 AND usage_type = $2 AND ($3::timestamptz IS NULL OR timestamp >= $3)",
|
||||||
|
)
|
||||||
|
.bind(*user_id.as_uuid())
|
||||||
|
.bind(usage_type_to_str(&usage_type))
|
||||||
|
.bind(since_dt)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_pg()?;
|
||||||
|
|
||||||
|
Ok(row.total as u64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// IngestTransaction (composite port)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pg_repo!(PostgresIngestTransaction);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl IngestTransaction for PostgresIngestTransaction {
|
||||||
|
async fn save_asset(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
|
use domain::ports::AssetRepository;
|
||||||
|
crate::PostgresAssetRepository::new(self.pool.clone())
|
||||||
|
.save(asset)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_session(&self, session: &IngestSession) -> Result<(), DomainError> {
|
||||||
|
PostgresIngestSessionRepository::new(self.pool.clone())
|
||||||
|
.save(session)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_quota(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Option<QuotaDefinition>, DomainError> {
|
||||||
|
PostgresQuotaRepository::new(self.pool.clone())
|
||||||
|
.find_by_owner(owner_id)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sum_usage(
|
||||||
|
&self,
|
||||||
|
user_id: &SystemId,
|
||||||
|
usage_type: UsageType,
|
||||||
|
since: Option<DateTimeStamp>,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
PostgresUsageLedgerRepository::new(self.pool.clone())
|
||||||
|
.sum_usage(user_id, usage_type, since)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn record_usage(&self, entry: &UsageLedgerEntry) -> Result<(), DomainError> {
|
||||||
|
PostgresUsageLedgerRepository::new(self.pool.clone())
|
||||||
|
.record(entry)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::StorageVolume, errors::DomainError, ports::StorageVolumeRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct StorageVolumeRow {
|
|
||||||
volume_id: Uuid,
|
|
||||||
volume_name: String,
|
|
||||||
uri_prefix: String,
|
|
||||||
is_writable: bool,
|
|
||||||
available_bytes: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<StorageVolumeRow> for StorageVolume {
|
|
||||||
fn from(r: StorageVolumeRow) -> Self {
|
|
||||||
Self {
|
|
||||||
volume_id: SystemId::from_uuid(r.volume_id),
|
|
||||||
volume_name: r.volume_name,
|
|
||||||
uri_prefix: r.uri_prefix,
|
|
||||||
is_writable: r.is_writable,
|
|
||||||
available_bytes: r.available_bytes as u64,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresStorageVolumeRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresStorageVolumeRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl StorageVolumeRepository for PostgresStorageVolumeRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<StorageVolume>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, StorageVolumeRow>(
|
|
||||||
"SELECT volume_id, volume_name, uri_prefix, is_writable, available_bytes
|
|
||||||
FROM storage_volumes WHERE volume_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_all(&self) -> Result<Vec<StorageVolume>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, StorageVolumeRow>(
|
|
||||||
"SELECT volume_id, volume_name, uri_prefix, is_writable, available_bytes
|
|
||||||
FROM storage_volumes",
|
|
||||||
)
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(rows.into_iter().map(Into::into).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, volume: &StorageVolume) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO storage_volumes (volume_id, volume_name, uri_prefix, is_writable, available_bytes)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (volume_id) DO UPDATE SET
|
|
||||||
volume_name = EXCLUDED.volume_name,
|
|
||||||
uri_prefix = EXCLUDED.uri_prefix,
|
|
||||||
is_writable = EXCLUDED.is_writable,
|
|
||||||
available_bytes = EXCLUDED.available_bytes",
|
|
||||||
)
|
|
||||||
.bind(*volume.volume_id.as_uuid())
|
|
||||||
.bind(&volume.volume_name)
|
|
||||||
.bind(&volume.uri_prefix)
|
|
||||||
.bind(volume.is_writable)
|
|
||||||
.bind(volume.available_bytes as i64)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM storage_volumes WHERE volume_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::{AssetTag, Tag, TagSource},
|
|
||||||
errors::DomainError,
|
|
||||||
ports::TagRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct TagRow {
|
|
||||||
tag_id: Uuid,
|
|
||||||
name: String,
|
|
||||||
tag_source: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct AssetTagRow {
|
|
||||||
asset_id: Uuid,
|
|
||||||
tag_id: Uuid,
|
|
||||||
tagged_by_user_id: Option<Uuid>,
|
|
||||||
confidence: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tag_source_from_str(s: &str) -> TagSource {
|
|
||||||
match s {
|
|
||||||
"ai_generated" => TagSource::AiGenerated,
|
|
||||||
"exif_extracted" => TagSource::ExifExtracted,
|
|
||||||
_ => TagSource::UserManual,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tag_source_to_str(s: &TagSource) -> &'static str {
|
|
||||||
match s {
|
|
||||||
TagSource::UserManual => "user_manual",
|
|
||||||
TagSource::AiGenerated => "ai_generated",
|
|
||||||
TagSource::ExifExtracted => "exif_extracted",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<TagRow> for Tag {
|
|
||||||
fn from(r: TagRow) -> Self {
|
|
||||||
Self {
|
|
||||||
tag_id: SystemId::from_uuid(r.tag_id),
|
|
||||||
name: r.name,
|
|
||||||
tag_source: tag_source_from_str(&r.tag_source),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<AssetTagRow> for AssetTag {
|
|
||||||
fn from(r: AssetTagRow) -> Self {
|
|
||||||
Self {
|
|
||||||
asset_id: SystemId::from_uuid(r.asset_id),
|
|
||||||
tag_id: SystemId::from_uuid(r.tag_id),
|
|
||||||
tagged_by_user_id: r.tagged_by_user_id.map(SystemId::from_uuid),
|
|
||||||
confidence: r.confidence,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresTagRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresTagRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl TagRepository for PostgresTagRepository {
|
|
||||||
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Tag>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, TagRow>(
|
|
||||||
"SELECT tag_id, name, tag_source FROM tags WHERE tag_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_name(&self, name: &str) -> Result<Option<Tag>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, TagRow>(
|
|
||||||
"SELECT tag_id, name, tag_source FROM tags WHERE name = $1",
|
|
||||||
)
|
|
||||||
.bind(name)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_tags_for_asset(
|
|
||||||
&self,
|
|
||||||
asset_id: &SystemId,
|
|
||||||
) -> Result<Vec<(Tag, AssetTag)>, DomainError> {
|
|
||||||
let rows = sqlx::query_as::<_, TagRow>(
|
|
||||||
"SELECT t.tag_id, t.name, t.tag_source
|
|
||||||
FROM tags t JOIN asset_tags at ON t.tag_id = at.tag_id
|
|
||||||
WHERE at.asset_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let at_rows = sqlx::query_as::<_, AssetTagRow>(
|
|
||||||
"SELECT asset_id, tag_id, tagged_by_user_id, confidence
|
|
||||||
FROM asset_tags WHERE asset_id = $1",
|
|
||||||
)
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.fetch_all(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
let tags: Vec<Tag> = rows.into_iter().map(Into::into).collect();
|
|
||||||
let asset_tags: Vec<AssetTag> = at_rows.into_iter().map(Into::into).collect();
|
|
||||||
|
|
||||||
Ok(tags.into_iter().zip(asset_tags).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_tag(&self, tag: &Tag) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO tags (tag_id, name, tag_source)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (tag_id) DO UPDATE SET name = EXCLUDED.name, tag_source = EXCLUDED.tag_source",
|
|
||||||
)
|
|
||||||
.bind(*tag.tag_id.as_uuid())
|
|
||||||
.bind(&tag.name)
|
|
||||||
.bind(tag_source_to_str(&tag.tag_source))
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save_asset_tag(&self, asset_tag: &AssetTag) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO asset_tags (asset_id, tag_id, tagged_by_user_id, confidence)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (asset_id, tag_id) DO UPDATE SET
|
|
||||||
tagged_by_user_id = EXCLUDED.tagged_by_user_id,
|
|
||||||
confidence = EXCLUDED.confidence",
|
|
||||||
)
|
|
||||||
.bind(*asset_tag.asset_id.as_uuid())
|
|
||||||
.bind(*asset_tag.tag_id.as_uuid())
|
|
||||||
.bind(asset_tag.tagged_by_user_id.as_ref().map(|id| *id.as_uuid()))
|
|
||||||
.bind(asset_tag.confidence)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_asset_tag(
|
|
||||||
&self,
|
|
||||||
asset_id: &SystemId,
|
|
||||||
tag_id: &SystemId,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM asset_tags WHERE asset_id = $1 AND tag_id = $2")
|
|
||||||
.bind(*asset_id.as_uuid())
|
|
||||||
.bind(*tag_id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError,
|
|
||||||
ports::UserRepository,
|
|
||||||
value_objects::{Email, PasswordHash, SystemId},
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct UserRow {
|
|
||||||
id: Uuid,
|
|
||||||
username: String,
|
|
||||||
email: String,
|
|
||||||
password_hash: String,
|
|
||||||
created_at: DateTime<Utc>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TryFrom<UserRow> for domain::entities::User {
|
|
||||||
type Error = DomainError;
|
|
||||||
fn try_from(r: UserRow) -> Result<Self, Self::Error> {
|
|
||||||
Ok(Self {
|
|
||||||
id: SystemId::from_uuid(r.id),
|
|
||||||
username: r.username,
|
|
||||||
email: Email::new(r.email)?,
|
|
||||||
password_hash: PasswordHash::from_hash(r.password_hash),
|
|
||||||
created_at: r.created_at,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresUserRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresUserRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UserRepository for PostgresUserRepository {
|
|
||||||
async fn find_by_id(
|
|
||||||
&self,
|
|
||||||
id: &SystemId,
|
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
|
||||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1",
|
|
||||||
)
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_email(
|
|
||||||
&self,
|
|
||||||
email: &Email,
|
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
|
||||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1",
|
|
||||||
)
|
|
||||||
.bind(email.as_str())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn find_by_username(
|
|
||||||
&self,
|
|
||||||
username: &str,
|
|
||||||
) -> Result<Option<domain::entities::User>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, UserRow>(
|
|
||||||
"SELECT id, username, email, password_hash, created_at FROM users WHERE username = $1",
|
|
||||||
)
|
|
||||||
.bind(username)
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
row.map(TryInto::try_into).transpose()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, user: &domain::entities::User) -> Result<(), DomainError> {
|
|
||||||
sqlx::query_as::<_, UserRow>(
|
|
||||||
"INSERT INTO users (id, username, email, password_hash, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
|
||||||
username = EXCLUDED.username,
|
|
||||||
email = EXCLUDED.email,
|
|
||||||
password_hash = EXCLUDED.password_hash
|
|
||||||
RETURNING id, username, email, password_hash, created_at",
|
|
||||||
)
|
|
||||||
.bind(*user.id.as_uuid())
|
|
||||||
.bind(&user.username)
|
|
||||||
.bind(user.email.as_str())
|
|
||||||
.bind(user.password_hash.as_str())
|
|
||||||
.bind(user.created_at)
|
|
||||||
.fetch_one(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM users WHERE id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
use crate::db::PgPool;
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use domain::{
|
|
||||||
entities::VisibilityFilter, errors::DomainError, ports::VisibilityFilterRepository,
|
|
||||||
value_objects::SystemId,
|
|
||||||
};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
|
||||||
struct VisibilityFilterRow {
|
|
||||||
filter_id: Uuid,
|
|
||||||
scope_id: Uuid,
|
|
||||||
role_id: Uuid,
|
|
||||||
hidden_fields: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<VisibilityFilterRow> for VisibilityFilter {
|
|
||||||
fn from(r: VisibilityFilterRow) -> Self {
|
|
||||||
Self {
|
|
||||||
filter_id: SystemId::from_uuid(r.filter_id),
|
|
||||||
scope_id: SystemId::from_uuid(r.scope_id),
|
|
||||||
role_id: SystemId::from_uuid(r.role_id),
|
|
||||||
hidden_fields: r.hidden_fields,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PostgresVisibilityFilterRepository {
|
|
||||||
pool: PgPool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PostgresVisibilityFilterRepository {
|
|
||||||
pub fn new(pool: PgPool) -> Self {
|
|
||||||
Self { pool }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl VisibilityFilterRepository for PostgresVisibilityFilterRepository {
|
|
||||||
async fn find_by_scope_and_role(
|
|
||||||
&self,
|
|
||||||
scope_id: &SystemId,
|
|
||||||
role_id: &SystemId,
|
|
||||||
) -> Result<Option<VisibilityFilter>, DomainError> {
|
|
||||||
let row = sqlx::query_as::<_, VisibilityFilterRow>(
|
|
||||||
"SELECT filter_id, scope_id, role_id, hidden_fields
|
|
||||||
FROM visibility_filters WHERE scope_id = $1 AND role_id = $2",
|
|
||||||
)
|
|
||||||
.bind(*scope_id.as_uuid())
|
|
||||||
.bind(*role_id.as_uuid())
|
|
||||||
.fetch_optional(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(row.map(Into::into))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn save(&self, filter: &VisibilityFilter) -> Result<(), DomainError> {
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO visibility_filters (filter_id, scope_id, role_id, hidden_fields)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
ON CONFLICT (filter_id) DO UPDATE SET
|
|
||||||
hidden_fields = EXCLUDED.hidden_fields",
|
|
||||||
)
|
|
||||||
.bind(*filter.filter_id.as_uuid())
|
|
||||||
.bind(*filter.scope_id.as_uuid())
|
|
||||||
.bind(*filter.role_id.as_uuid())
|
|
||||||
.bind(&filter.hidden_fields)
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
|
||||||
sqlx::query("DELETE FROM visibility_filters WHERE filter_id = $1")
|
|
||||||
.bind(*id.as_uuid())
|
|
||||||
.execute(&self.pool)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
crates/adapters/sidecar/Cargo.toml
Normal file
11
crates/adapters/sidecar/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-sidecar"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
async-trait = { workspace = true }
|
||||||
|
tokio = { workspace = true, features = ["fs"] }
|
||||||
|
xmp_toolkit = "1.11"
|
||||||
|
tracing = { workspace = true }
|
||||||
139
crates/adapters/sidecar/src/lib.rs
Normal file
139
crates/adapters/sidecar/src/lib.rs
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::SidecarWriterPort,
|
||||||
|
value_objects::{MetadataValue, StructuredData},
|
||||||
|
};
|
||||||
|
use xmp_toolkit::{
|
||||||
|
XmpMeta, XmpValue,
|
||||||
|
xmp_ns::{DC, EXIF, XMP},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct XmpSidecarWriter;
|
||||||
|
|
||||||
|
const EXIF_TAGS: &[&str] = &[
|
||||||
|
"DateTimeOriginal",
|
||||||
|
"ExposureTime",
|
||||||
|
"FNumber",
|
||||||
|
"ISOSpeedRatings",
|
||||||
|
"FocalLength",
|
||||||
|
"Make",
|
||||||
|
"Model",
|
||||||
|
"LensModel",
|
||||||
|
"GPSLatitude",
|
||||||
|
"GPSLongitude",
|
||||||
|
"GPSAltitude",
|
||||||
|
"Orientation",
|
||||||
|
"ImageWidth",
|
||||||
|
"ImageHeight",
|
||||||
|
"Flash",
|
||||||
|
"MeteringMode",
|
||||||
|
"WhiteBalance",
|
||||||
|
"ExposureProgram",
|
||||||
|
"ExposureBiasValue",
|
||||||
|
"ModifyDate",
|
||||||
|
"Software",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SidecarWriterPort for XmpSidecarWriter {
|
||||||
|
fn format_name(&self) -> &str {
|
||||||
|
"xmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_sidecar(&self, data: &StructuredData, path: &str) -> Result<(), DomainError> {
|
||||||
|
let mut xmp =
|
||||||
|
XmpMeta::new().map_err(|e| DomainError::Internal(format!("xmp init failed: {e}")))?;
|
||||||
|
|
||||||
|
register_namespaces()?;
|
||||||
|
|
||||||
|
for (key, value) in data.inner() {
|
||||||
|
let value_str = match value {
|
||||||
|
MetadataValue::String(s) => s.clone(),
|
||||||
|
MetadataValue::Integer(i) => i.to_string(),
|
||||||
|
MetadataValue::Float(f) => f.to_string(),
|
||||||
|
MetadataValue::Boolean(b) => b.to_string(),
|
||||||
|
MetadataValue::Null => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ns = if EXIF_TAGS.contains(&key.as_str()) || key.starts_with("track:") {
|
||||||
|
EXIF
|
||||||
|
} else if key == "title" || key == "description" || key == "subject" {
|
||||||
|
DC
|
||||||
|
} else {
|
||||||
|
XMP
|
||||||
|
};
|
||||||
|
|
||||||
|
set_prop(&mut xmp, ns, key, &value_str)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let xmp_str = xmp.to_string();
|
||||||
|
let path = path.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if let Some(parent) = std::path::Path::new(&path).parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| DomainError::Internal(format!("mkdir failed: {e}")))?;
|
||||||
|
}
|
||||||
|
std::fs::write(&path, xmp_str)
|
||||||
|
.map_err(|e| DomainError::Internal(format!("write failed: {e}")))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(format!("spawn_blocking failed: {e}")))?
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_sidecar(&self, path: &str) -> Result<StructuredData, DomainError> {
|
||||||
|
let path = path.to_string();
|
||||||
|
let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
|
||||||
|
if e.kind() == std::io::ErrorKind::NotFound {
|
||||||
|
DomainError::NotFound(format!("sidecar not found: {path}"))
|
||||||
|
} else {
|
||||||
|
DomainError::Internal(format!("read failed: {e}"))
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let xmp: XmpMeta = content
|
||||||
|
.parse()
|
||||||
|
.map_err(|e| DomainError::Internal(format!("xmp parse failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut data = StructuredData::new();
|
||||||
|
|
||||||
|
for ns in [DC, EXIF, XMP] {
|
||||||
|
let iter = xmp.iter(xmp_toolkit::IterOptions::default().schema_ns(ns));
|
||||||
|
for prop in iter {
|
||||||
|
if prop.name.is_empty() || prop.value.value.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = prop
|
||||||
|
.name
|
||||||
|
.split(':')
|
||||||
|
.next_back()
|
||||||
|
.unwrap_or(&prop.name)
|
||||||
|
.to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
data.insert(key, MetadataValue::String(prop.value.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_namespaces() -> Result<(), DomainError> {
|
||||||
|
XmpMeta::register_namespace(DC, "dc")
|
||||||
|
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
|
||||||
|
XmpMeta::register_namespace(EXIF, "exif")
|
||||||
|
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
|
||||||
|
XmpMeta::register_namespace(XMP, "xmp")
|
||||||
|
.map_err(|e| DomainError::Internal(format!("ns register failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_prop(xmp: &mut XmpMeta, ns: &str, key: &str, value: &str) -> Result<(), DomainError> {
|
||||||
|
xmp.set_property(ns, key, &XmpValue::from(value))
|
||||||
|
.map_err(|e| DomainError::Internal(format!("set {key} failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
45
crates/adapters/sidecar/src/tests.rs
Normal file
45
crates/adapters/sidecar/src/tests.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
use crate::XmpSidecarWriter;
|
||||||
|
use domain::{
|
||||||
|
ports::SidecarWriterPort,
|
||||||
|
value_objects::{MetadataValue, StructuredData},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn sample_metadata() -> StructuredData {
|
||||||
|
let mut data = StructuredData::new();
|
||||||
|
data.insert("Make", MetadataValue::String("Canon".into()));
|
||||||
|
data.insert("Model", MetadataValue::String("EOS R5".into()));
|
||||||
|
data.insert(
|
||||||
|
"DateTimeOriginal",
|
||||||
|
MetadataValue::String("2024:06:15 14:30:00".into()),
|
||||||
|
);
|
||||||
|
data.insert("ISOSpeedRatings", MetadataValue::Integer(800));
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_and_read_roundtrip() {
|
||||||
|
let writer = XmpSidecarWriter;
|
||||||
|
let data = sample_metadata();
|
||||||
|
let path = "/tmp/k-photos-test-sidecar-roundtrip.xmp";
|
||||||
|
|
||||||
|
writer.write_sidecar(&data, path).await.unwrap();
|
||||||
|
let read_back = writer.read_sidecar(path).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(read_back.get_string("Make"), Some("Canon"));
|
||||||
|
assert_eq!(read_back.get_string("Model"), Some("EOS R5"));
|
||||||
|
|
||||||
|
tokio::fs::remove_file(path).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_missing_returns_not_found() {
|
||||||
|
let writer = XmpSidecarWriter;
|
||||||
|
let result = writer.read_sidecar("/tmp/nonexistent-xmp-file.xmp").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn format_name_is_xmp() {
|
||||||
|
let writer = XmpSidecarWriter;
|
||||||
|
assert_eq!(writer.format_name(), "xmp");
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -146,199 +146,3 @@ impl StorageReader for ObjectStorageAdapter {
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use domain::ports::{StorageReader, StorageWriter};
|
|
||||||
use futures::stream;
|
|
||||||
use object_store::memory::InMemory;
|
|
||||||
|
|
||||||
fn make_adapter() -> ObjectStorageAdapter {
|
|
||||||
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn one_shot(data: &'static [u8]) -> DataStream {
|
|
||||||
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn put_get_roundtrip() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("hello.txt", one_shot(b"world")).await.unwrap();
|
|
||||||
let mut s = a.get("hello.txt").await.unwrap();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
while let Some(chunk) = s.next().await {
|
|
||||||
out.extend_from_slice(&chunk.unwrap());
|
|
||||||
}
|
|
||||||
assert_eq!(out, b"world");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_missing_is_not_found() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.get("nope.txt").await,
|
|
||||||
Err(DomainError::NotFound(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_is_idempotent() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.delete("nope.txt").await.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delete_removes_key() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("file.txt", one_shot(b"data")).await.unwrap();
|
|
||||||
a.delete("file.txt").await.unwrap();
|
|
||||||
assert!(matches!(
|
|
||||||
a.get("file.txt").await,
|
|
||||||
Err(DomainError::NotFound(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_returns_keys_under_prefix() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
|
||||||
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
|
|
||||||
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
|
|
||||||
let keys = a.list(Some("docs")).await.unwrap();
|
|
||||||
assert_eq!(keys.len(), 2);
|
|
||||||
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
|
|
||||||
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_none_returns_all() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("a.txt", one_shot(b"1")).await.unwrap();
|
|
||||||
a.put("b.txt", one_shot(b"2")).await.unwrap();
|
|
||||||
let keys = a.list(None).await.unwrap();
|
|
||||||
assert_eq!(keys.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_empty_key() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.put("", one_shot(b"x")).await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
|
|
||||||
assert!(matches!(
|
|
||||||
a.delete("").await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_absolute_key() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.put("/etc/passwd", one_shot(b"x")).await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_path_traversal() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.get("../escape").await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
a.get("a/../../../etc").await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_dot_segment() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.put("./file.txt", one_shot(b"x")).await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn rejects_invalid_list_prefix() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.list(Some("")).await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
a.list(Some("../escape")).await,
|
|
||||||
Err(DomainError::Validation(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn put_overwrites_existing() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("file.txt", one_shot(b"version1")).await.unwrap();
|
|
||||||
a.put("file.txt", one_shot(b"version2")).await.unwrap();
|
|
||||||
let mut s = a.get("file.txt").await.unwrap();
|
|
||||||
let mut out = Vec::new();
|
|
||||||
while let Some(chunk) = s.next().await {
|
|
||||||
out.extend_from_slice(&chunk.unwrap());
|
|
||||||
}
|
|
||||||
assert_eq!(out, b"version2");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn list_returns_exact_key_paths() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
|
||||||
let mut keys = a.list(Some("docs")).await.unwrap();
|
|
||||||
keys.sort();
|
|
||||||
assert_eq!(keys, vec!["docs/readme.txt"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn put_bytes_get_bytes_roundtrip() {
|
|
||||||
let a = make_adapter();
|
|
||||||
a.put_bytes("data.bin", Bytes::from("hello bytes"))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
let got = a.get_bytes("data.bin").await.unwrap();
|
|
||||||
assert_eq!(got.as_ref(), b"hello bytes");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn get_bytes_missing_is_not_found() {
|
|
||||||
let a = make_adapter();
|
|
||||||
assert!(matches!(
|
|
||||||
a.get_bytes("nope.bin").await,
|
|
||||||
Err(DomainError::NotFound(_))
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_rejects_traversal_prefix() {
|
|
||||||
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
|
|
||||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_rejects_absolute_prefix() {
|
|
||||||
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
|
|
||||||
assert!(matches!(result, Err(DomainError::Validation(_))));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_accepts_empty_prefix() {
|
|
||||||
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_accepts_valid_prefix() {
|
|
||||||
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
197
crates/adapters/storage/tests/adapter_tests.rs
Normal file
197
crates/adapters/storage/tests/adapter_tests.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use adapters_storage::ObjectStorageAdapter;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::ports::{DataStream, StorageReader, StorageWriter};
|
||||||
|
use futures::stream;
|
||||||
|
use futures::stream::StreamExt;
|
||||||
|
use object_store::memory::InMemory;
|
||||||
|
|
||||||
|
fn make_adapter() -> ObjectStorageAdapter {
|
||||||
|
ObjectStorageAdapter::new(Arc::new(InMemory::new()), "test").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_shot(data: &'static [u8]) -> DataStream {
|
||||||
|
Box::pin(stream::once(async move { Ok(Bytes::from(data)) }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_get_roundtrip() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("hello.txt", one_shot(b"world")).await.unwrap();
|
||||||
|
let mut s = a.get("hello.txt").await.unwrap();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(chunk) = s.next().await {
|
||||||
|
out.extend_from_slice(&chunk.unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(out, b"world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_missing_is_not_found() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.get("nope.txt").await,
|
||||||
|
Err(DomainError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_is_idempotent() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.delete("nope.txt").await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_key() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("file.txt", one_shot(b"data")).await.unwrap();
|
||||||
|
a.delete("file.txt").await.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
a.get("file.txt").await,
|
||||||
|
Err(DomainError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_returns_keys_under_prefix() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
||||||
|
a.put("docs/guide.txt", one_shot(b"y")).await.unwrap();
|
||||||
|
a.put("other/file.txt", one_shot(b"z")).await.unwrap();
|
||||||
|
let keys = a.list(Some("docs")).await.unwrap();
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
assert!(keys.iter().any(|k| k.ends_with("readme.txt")));
|
||||||
|
assert!(keys.iter().any(|k| k.ends_with("guide.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_none_returns_all() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("a.txt", one_shot(b"1")).await.unwrap();
|
||||||
|
a.put("b.txt", one_shot(b"2")).await.unwrap();
|
||||||
|
let keys = a.list(None).await.unwrap();
|
||||||
|
assert_eq!(keys.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_empty_key() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.put("", one_shot(b"x")).await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(a.get("").await, Err(DomainError::Validation(_))));
|
||||||
|
assert!(matches!(
|
||||||
|
a.delete("").await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_absolute_key() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.put("/etc/passwd", one_shot(b"x")).await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_path_traversal() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.get("../escape").await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
a.get("a/../../../etc").await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_dot_segment() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.put("./file.txt", one_shot(b"x")).await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejects_invalid_list_prefix() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.list(Some("")).await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
a.list(Some("../escape")).await,
|
||||||
|
Err(DomainError::Validation(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_overwrites_existing() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("file.txt", one_shot(b"version1")).await.unwrap();
|
||||||
|
a.put("file.txt", one_shot(b"version2")).await.unwrap();
|
||||||
|
let mut s = a.get("file.txt").await.unwrap();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(chunk) = s.next().await {
|
||||||
|
out.extend_from_slice(&chunk.unwrap());
|
||||||
|
}
|
||||||
|
assert_eq!(out, b"version2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_returns_exact_key_paths() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put("docs/readme.txt", one_shot(b"x")).await.unwrap();
|
||||||
|
let mut keys = a.list(Some("docs")).await.unwrap();
|
||||||
|
keys.sort();
|
||||||
|
assert_eq!(keys, vec!["docs/readme.txt"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn put_bytes_get_bytes_roundtrip() {
|
||||||
|
let a = make_adapter();
|
||||||
|
a.put_bytes("data.bin", Bytes::from("hello bytes"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let got = a.get_bytes("data.bin").await.unwrap();
|
||||||
|
assert_eq!(got.as_ref(), b"hello bytes");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_bytes_missing_is_not_found() {
|
||||||
|
let a = make_adapter();
|
||||||
|
assert!(matches!(
|
||||||
|
a.get_bytes("nope.bin").await,
|
||||||
|
Err(DomainError::NotFound(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rejects_traversal_prefix() {
|
||||||
|
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "../evil");
|
||||||
|
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_rejects_absolute_prefix() {
|
||||||
|
let result = ObjectStorageAdapter::new(Arc::new(InMemory::new()), "/root");
|
||||||
|
assert!(matches!(result, Err(DomainError::Validation(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_accepts_empty_prefix() {
|
||||||
|
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_accepts_valid_prefix() {
|
||||||
|
assert!(ObjectStorageAdapter::new(Arc::new(InMemory::new()), "my-bucket/data").is_ok());
|
||||||
|
}
|
||||||
9
crates/adapters/thumbnail/Cargo.toml
Normal file
9
crates/adapters/thumbnail/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "adapters-thumbnail"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
domain = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
image = "0.25"
|
||||||
57
crates/adapters/thumbnail/src/lib.rs
Normal file
57
crates/adapters/thumbnail/src/lib.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
use bytes::Bytes;
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{ThumbnailGeneratorPort, ThumbnailOutput},
|
||||||
|
};
|
||||||
|
use image::{DynamicImage, ImageFormat, load_from_memory};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
pub struct ImageThumbnailGenerator;
|
||||||
|
|
||||||
|
impl ThumbnailGeneratorPort for ImageThumbnailGenerator {
|
||||||
|
fn generate(
|
||||||
|
&self,
|
||||||
|
source: &Bytes,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
format: &str,
|
||||||
|
) -> Result<ThumbnailOutput, DomainError> {
|
||||||
|
let img = load_from_memory(source)
|
||||||
|
.map_err(|e| DomainError::Internal(format!("failed to decode image: {e}")))?;
|
||||||
|
|
||||||
|
let thumb = img.thumbnail(width, height);
|
||||||
|
let (img_format, mime) = parse_format(format)?;
|
||||||
|
|
||||||
|
let encoded = encode(&thumb, img_format)?;
|
||||||
|
let actual_width = thumb.width();
|
||||||
|
let actual_height = thumb.height();
|
||||||
|
|
||||||
|
Ok(ThumbnailOutput {
|
||||||
|
bytes: Bytes::from(encoded),
|
||||||
|
width: actual_width,
|
||||||
|
height: actual_height,
|
||||||
|
mime_type: mime.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_format(s: &str) -> Result<(ImageFormat, &'static str), DomainError> {
|
||||||
|
match s {
|
||||||
|
"jpeg" | "jpg" => Ok((ImageFormat::Jpeg, "image/jpeg")),
|
||||||
|
"webp" => Ok((ImageFormat::WebP, "image/webp")),
|
||||||
|
"png" => Ok((ImageFormat::Png, "image/png")),
|
||||||
|
other => Err(DomainError::Validation(format!(
|
||||||
|
"unsupported thumbnail format: {other}"
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode(img: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, DomainError> {
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, format)
|
||||||
|
.map_err(|e| DomainError::Internal(format!("failed to encode thumbnail: {e}")))?;
|
||||||
|
Ok(buf.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
66
crates/adapters/thumbnail/src/tests.rs
Normal file
66
crates/adapters/thumbnail/src/tests.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use crate::ImageThumbnailGenerator;
|
||||||
|
use bytes::Bytes;
|
||||||
|
use domain::ports::ThumbnailGeneratorPort as _;
|
||||||
|
|
||||||
|
fn make_test_png() -> Bytes {
|
||||||
|
use image::{ImageFormat, RgbImage};
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
let img = RgbImage::new(100, 200);
|
||||||
|
let mut buf = Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut buf, ImageFormat::Png).unwrap();
|
||||||
|
Bytes::from(buf.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_jpeg_thumbnail() {
|
||||||
|
let generator = ImageThumbnailGenerator;
|
||||||
|
let source = make_test_png();
|
||||||
|
|
||||||
|
let out = generator.generate(&source, 50, 50, "jpeg").unwrap();
|
||||||
|
|
||||||
|
assert!(out.width <= 50);
|
||||||
|
assert!(out.height <= 50);
|
||||||
|
assert_eq!(out.mime_type, "image/jpeg");
|
||||||
|
assert!(!out.bytes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generates_webp_thumbnail() {
|
||||||
|
let generator = ImageThumbnailGenerator;
|
||||||
|
let source = make_test_png();
|
||||||
|
|
||||||
|
let out = generator.generate(&source, 30, 30, "webp").unwrap();
|
||||||
|
|
||||||
|
assert!(out.width <= 30);
|
||||||
|
assert!(out.height <= 30);
|
||||||
|
assert_eq!(out.mime_type, "image/webp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_aspect_ratio() {
|
||||||
|
let generator = ImageThumbnailGenerator;
|
||||||
|
let source = make_test_png(); // 100x200
|
||||||
|
|
||||||
|
let out = generator.generate(&source, 50, 50, "png").unwrap();
|
||||||
|
|
||||||
|
// 100x200 → fits in 50x50 → 25x50
|
||||||
|
assert_eq!(out.width, 25);
|
||||||
|
assert_eq!(out.height, 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unsupported_format() {
|
||||||
|
let generator = ImageThumbnailGenerator;
|
||||||
|
let source = make_test_png();
|
||||||
|
|
||||||
|
let result = generator.generate(&source, 50, 50, "bmp");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_garbage_input() {
|
||||||
|
let generator = ImageThumbnailGenerator;
|
||||||
|
let result = generator.generate(&Bytes::from_static(b"not an image"), 50, 50, "jpeg");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ impl RegisterAssetHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
self.event_pub
|
self.event_pub
|
||||||
.publish(DomainEvent::AssetIngested {
|
.publish(&DomainEvent::AssetIngested {
|
||||||
asset_id: asset.asset_id,
|
asset_id: asset.asset_id,
|
||||||
owner_user_id: asset.owner_user_id,
|
owner_user_id: asset.owner_user_id,
|
||||||
timestamp: DateTimeStamp::now(),
|
timestamp: DateTimeStamp::now(),
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@ impl UpdateMetadataHandler {
|
|||||||
self.metadata_repo.save(&metadata).await?;
|
self.metadata_repo.save(&metadata).await?;
|
||||||
|
|
||||||
self.event_pub
|
self.event_pub
|
||||||
.publish(DomainEvent::MetadataUpdated {
|
.publish(&DomainEvent::MetadataUpdated {
|
||||||
asset_id: cmd.asset_id,
|
asset_id: cmd.asset_id,
|
||||||
updated_by: cmd.user_id,
|
updated_by: cmd.user_id,
|
||||||
timestamp: DateTimeStamp::now(),
|
timestamp: DateTimeStamp::now(),
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
pub mod visibility;
|
||||||
|
|
||||||
|
pub use commands::create_stack::{
|
||||||
|
CreateStackCommand, CreateStackHandler, DeleteStackCommand, DeleteStackHandler,
|
||||||
|
};
|
||||||
|
pub use commands::delete_asset::{DeleteAssetCommand, DeleteAssetHandler};
|
||||||
|
pub use commands::restore_asset::{RestoreAssetCommand, RestoreAssetHandler};
|
||||||
|
pub use commands::detect_live_photos::{DetectLivePhotosCommand, DetectLivePhotosHandler};
|
||||||
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
|
pub use commands::register_asset::{RegisterAssetCommand, RegisterAssetHandler};
|
||||||
|
pub use commands::resolve_duplicate::{
|
||||||
|
ListDuplicatesHandler, ListDuplicatesQuery, ResolveDuplicateCommand, ResolveDuplicateHandler,
|
||||||
|
};
|
||||||
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
|
pub use commands::update_metadata::{UpdateMetadataCommand, UpdateMetadataHandler};
|
||||||
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
|
pub use queries::get_asset::{GetAssetHandler, GetAssetQuery};
|
||||||
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery};
|
pub use queries::get_date_summary::{DateSummaryEntry, GetDateSummaryHandler, GetDateSummaryQuery};
|
||||||
|
pub use queries::get_stack::{GetStackHandler, GetStackQuery};
|
||||||
|
pub use queries::get_timeline::{GetTimelineHandler, GetTimelineQuery, TimelineResult};
|
||||||
|
pub use queries::list_stacks::{ListStacksHandler, ListStacksQuery};
|
||||||
|
pub use queries::list_trash::{ListTrashHandler, ListTrashQuery, TrashResult};
|
||||||
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
|
pub use queries::read_asset_file::{AssetFileResult, ReadAssetFileHandler, ReadAssetFileQuery};
|
||||||
|
pub use queries::read_derivative::{
|
||||||
|
DerivativeFileResult, ReadDerivativeHandler, ReadDerivativeQuery,
|
||||||
|
};
|
||||||
|
pub use queries::search_assets::{SearchAssetsHandler, SearchAssetsQuery, SearchResult};
|
||||||
|
pub use visibility::VisibilityFilteredAssetRepository;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
|
||||||
use domain::{
|
use domain::{
|
||||||
catalog::entities::Asset,
|
catalog::entities::Asset,
|
||||||
catalog::services::resolve_metadata,
|
catalog::services::resolve_metadata,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AssetMetadataRepository, AssetRepository},
|
ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
|
||||||
value_objects::{StructuredData, SystemId},
|
value_objects::{StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -16,6 +17,7 @@ pub struct GetAssetQuery {
|
|||||||
pub struct GetAssetHandler {
|
pub struct GetAssetHandler {
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||||
|
share_repo: Option<Arc<dyn ShareRepository>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetAssetHandler {
|
impl GetAssetHandler {
|
||||||
@@ -26,6 +28,28 @@ impl GetAssetHandler {
|
|||||||
Self {
|
Self {
|
||||||
asset_repo,
|
asset_repo,
|
||||||
metadata_repo,
|
metadata_repo,
|
||||||
|
share_repo: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable sharing-aware visibility filtering. When set, the handler
|
||||||
|
/// wraps the inner `AssetRepository` with a `VisibilityFilteredAssetRepository`
|
||||||
|
/// so that shared assets are visible to the caller.
|
||||||
|
pub fn with_visibility_filter(mut self, share_repo: Arc<dyn ShareRepository>) -> Self {
|
||||||
|
self.share_repo = Some(share_repo);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the effective asset repo — wrapped with a visibility filter
|
||||||
|
/// when a `ShareRepository` has been configured, otherwise the raw inner repo.
|
||||||
|
fn effective_repo(&self, caller_id: SystemId) -> Arc<dyn AssetRepository> {
|
||||||
|
match &self.share_repo {
|
||||||
|
Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new(
|
||||||
|
self.asset_repo.clone(),
|
||||||
|
share_repo.clone(),
|
||||||
|
caller_id,
|
||||||
|
)),
|
||||||
|
None => self.asset_repo.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,13 +57,16 @@ impl GetAssetHandler {
|
|||||||
&self,
|
&self,
|
||||||
query: GetAssetQuery,
|
query: GetAssetQuery,
|
||||||
) -> Result<(Asset, StructuredData), DomainError> {
|
) -> Result<(Asset, StructuredData), DomainError> {
|
||||||
let asset = self
|
let repo = self.effective_repo(query.user_id);
|
||||||
.asset_repo
|
|
||||||
|
let asset = repo
|
||||||
.find_by_id(&query.asset_id)
|
.find_by_id(&query.asset_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("Asset {} not found", query.asset_id)))?;
|
||||||
|
|
||||||
if asset.owner_user_id != query.user_id {
|
// When the visibility filter is active it already enforces access.
|
||||||
|
// When it is not, fall back to the original owner-only check.
|
||||||
|
if self.share_repo.is_none() && asset.owner_user_id != query.user_id {
|
||||||
return Err(DomainError::Forbidden("Access denied".to_string()));
|
return Err(DomainError::Forbidden("Access denied".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
|
use crate::catalog::visibility::VisibilityFilteredAssetRepository;
|
||||||
use domain::{
|
use domain::{
|
||||||
catalog::entities::Asset,
|
catalog::entities::Asset,
|
||||||
catalog::services::resolve_metadata,
|
catalog::services::resolve_metadata,
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{AssetMetadataRepository, AssetRepository},
|
ports::{AssetMetadataRepository, AssetRepository, ShareRepository},
|
||||||
value_objects::{StructuredData, SystemId},
|
value_objects::{StructuredData, SystemId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -10,13 +11,20 @@ use std::sync::Arc;
|
|||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct GetTimelineQuery {
|
pub struct GetTimelineQuery {
|
||||||
pub owner_id: SystemId,
|
pub owner_id: SystemId,
|
||||||
|
pub caller_id: Option<SystemId>,
|
||||||
pub limit: u32,
|
pub limit: u32,
|
||||||
pub offset: u32,
|
pub offset: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct TimelineResult {
|
||||||
|
pub items: Vec<(Asset, StructuredData)>,
|
||||||
|
pub total: u64,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct GetTimelineHandler {
|
pub struct GetTimelineHandler {
|
||||||
asset_repo: Arc<dyn AssetRepository>,
|
asset_repo: Arc<dyn AssetRepository>,
|
||||||
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
metadata_repo: Arc<dyn AssetMetadataRepository>,
|
||||||
|
share_repo: Option<Arc<dyn ShareRepository>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GetTimelineHandler {
|
impl GetTimelineHandler {
|
||||||
@@ -27,25 +35,52 @@ impl GetTimelineHandler {
|
|||||||
Self {
|
Self {
|
||||||
asset_repo,
|
asset_repo,
|
||||||
metadata_repo,
|
metadata_repo,
|
||||||
|
share_repo: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
/// Enable sharing-aware visibility filtering on timeline queries.
|
||||||
&self,
|
pub fn with_visibility_filter(mut self, share_repo: Arc<dyn ShareRepository>) -> Self {
|
||||||
query: GetTimelineQuery,
|
self.share_repo = Some(share_repo);
|
||||||
) -> Result<Vec<(Asset, StructuredData)>, DomainError> {
|
self
|
||||||
let assets = self
|
}
|
||||||
.asset_repo
|
|
||||||
|
fn effective_repo(&self, caller_id: SystemId) -> Arc<dyn AssetRepository> {
|
||||||
|
match &self.share_repo {
|
||||||
|
Some(share_repo) => Arc::new(VisibilityFilteredAssetRepository::new(
|
||||||
|
self.asset_repo.clone(),
|
||||||
|
share_repo.clone(),
|
||||||
|
caller_id,
|
||||||
|
)),
|
||||||
|
None => self.asset_repo.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(&self, query: GetTimelineQuery) -> Result<TimelineResult, DomainError> {
|
||||||
|
let caller_id = query.caller_id.unwrap_or(query.owner_id);
|
||||||
|
let repo = self.effective_repo(caller_id);
|
||||||
|
|
||||||
|
let total = repo.count_by_owner(&query.owner_id).await?;
|
||||||
|
let assets = repo
|
||||||
.find_by_owner(&query.owner_id, query.limit, query.offset)
|
.find_by_owner(&query.owner_id, query.limit, query.offset)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(assets.len());
|
let asset_ids: Vec<SystemId> = assets.iter().map(|a| a.asset_id).collect();
|
||||||
for asset in assets {
|
let all_layers = self.metadata_repo.find_by_assets(&asset_ids).await?;
|
||||||
let layers = self.metadata_repo.find_by_asset(&asset.asset_id).await?;
|
|
||||||
let resolved = resolve_metadata(&layers);
|
|
||||||
results.push((asset, resolved));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(results)
|
let items = assets
|
||||||
|
.into_iter()
|
||||||
|
.map(|asset| {
|
||||||
|
let layers: Vec<_> = all_layers
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.asset_id == asset.asset_id)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
let resolved = resolve_metadata(&layers);
|
||||||
|
(asset, resolved)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(TimelineResult { items, total })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,3 +1,9 @@
|
|||||||
pub mod get_asset;
|
pub mod get_asset;
|
||||||
|
pub mod get_date_summary;
|
||||||
|
pub mod list_trash;
|
||||||
|
pub mod get_stack;
|
||||||
pub mod get_timeline;
|
pub mod get_timeline;
|
||||||
|
pub mod list_stacks;
|
||||||
pub mod read_asset_file;
|
pub mod read_asset_file;
|
||||||
|
pub mod read_derivative;
|
||||||
|
pub mod search_assets;
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
82
crates/application/src/catalog/queries/read_derivative.rs
Normal file
82
crates/application/src/catalog/queries/read_derivative.rs
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
use domain::{
|
||||||
|
entities::{DerivativeProfile, GenerationStatus},
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{DataStream, DerivativeRepository, FileStoragePort},
|
||||||
|
value_objects::SystemId,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub struct ReadDerivativeQuery {
|
||||||
|
pub asset_id: SystemId,
|
||||||
|
pub profile: DerivativeProfile,
|
||||||
|
pub caller_id: SystemId,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DerivativeFileResult {
|
||||||
|
pub stream: DataStream,
|
||||||
|
pub size: u64,
|
||||||
|
pub mime_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ReadDerivativeHandler {
|
||||||
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
|
asset_repo: Arc<dyn domain::ports::AssetRepository>,
|
||||||
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReadDerivativeHandler {
|
||||||
|
pub fn new(
|
||||||
|
derivative_repo: Arc<dyn DerivativeRepository>,
|
||||||
|
asset_repo: Arc<dyn domain::ports::AssetRepository>,
|
||||||
|
file_storage: Arc<dyn FileStoragePort>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
derivative_repo,
|
||||||
|
asset_repo,
|
||||||
|
file_storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
&self,
|
||||||
|
query: ReadDerivativeQuery,
|
||||||
|
) -> Result<DerivativeFileResult, DomainError> {
|
||||||
|
let asset = self
|
||||||
|
.asset_repo
|
||||||
|
.find_by_id(&query.asset_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("Asset not found".into()))?;
|
||||||
|
if asset.owner_user_id != query.caller_id {
|
||||||
|
return Err(DomainError::Forbidden("Access denied".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivative = self
|
||||||
|
.derivative_repo
|
||||||
|
.find_by_asset_and_profile(&query.asset_id, query.profile)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
DomainError::NotFound(format!(
|
||||||
|
"Derivative {:?} not found for asset {}",
|
||||||
|
query.profile, query.asset_id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if derivative.generation_status != GenerationStatus::Ready {
|
||||||
|
return Err(DomainError::NotFound(format!(
|
||||||
|
"Derivative {:?} not ready for asset {}",
|
||||||
|
query.profile, query.asset_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (stream, size) = self
|
||||||
|
.file_storage
|
||||||
|
.open_file(&derivative.storage_path)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(DerivativeFileResult {
|
||||||
|
stream,
|
||||||
|
size,
|
||||||
|
mime_type: derivative.mime_type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
323
crates/application/src/catalog/visibility.rs
Normal file
323
crates/application/src/catalog/visibility.rs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::{
|
||||||
|
catalog::entities::{Asset, AssetFilters},
|
||||||
|
errors::DomainError,
|
||||||
|
ports::{AssetRepository, ShareRepository},
|
||||||
|
sharing::entities::ShareTarget,
|
||||||
|
value_objects::{Checksum, SystemId},
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::OnceCell;
|
||||||
|
|
||||||
|
pub struct VisibilityFilteredAssetRepository {
|
||||||
|
inner: Arc<dyn AssetRepository>,
|
||||||
|
share_repo: Arc<dyn ShareRepository>,
|
||||||
|
caller_id: SystemId,
|
||||||
|
caller_targets: OnceCell<Vec<ShareTarget>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisibilityFilteredAssetRepository {
|
||||||
|
pub fn new(
|
||||||
|
inner: Arc<dyn AssetRepository>,
|
||||||
|
share_repo: Arc<dyn ShareRepository>,
|
||||||
|
caller_id: SystemId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
inner,
|
||||||
|
share_repo,
|
||||||
|
caller_id,
|
||||||
|
caller_targets: OnceCell::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_caller_targets(&self) -> Result<&[ShareTarget], DomainError> {
|
||||||
|
self.caller_targets
|
||||||
|
.get_or_try_init(|| async {
|
||||||
|
self.share_repo.find_targets_for_user(&self.caller_id).await
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map(|v| v.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn caller_can_access(&self, asset: &Asset) -> Result<bool, DomainError> {
|
||||||
|
if asset.owner_user_id == self.caller_id {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopes = self
|
||||||
|
.share_repo
|
||||||
|
.find_scopes_for_resource(&asset.asset_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if scopes.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let caller_targets = self.get_caller_targets().await?;
|
||||||
|
|
||||||
|
for scope in &scopes {
|
||||||
|
if scope.is_expired() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if caller_targets.iter().any(|t| t.scope_id == scope.scope_id) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn filter_visible(&self, assets: Vec<Asset>) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
let mut visible = Vec::with_capacity(assets.len());
|
||||||
|
for asset in assets {
|
||||||
|
if self.caller_can_access(&asset).await? {
|
||||||
|
visible.push(asset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AssetRepository for VisibilityFilteredAssetRepository {
|
||||||
|
async fn find_by_id(&self, id: &SystemId) -> Result<Option<Asset>, DomainError> {
|
||||||
|
let asset = self.inner.find_by_id(id).await?;
|
||||||
|
match asset {
|
||||||
|
Some(a) if self.caller_can_access(&a).await? => Ok(Some(a)),
|
||||||
|
_ => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_checksum(&self, checksum: &Checksum) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
let assets = self.inner.find_by_checksum(checksum).await?;
|
||||||
|
self.filter_visible(assets).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_owner(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
if owner_id == &self.caller_id {
|
||||||
|
return self.inner.find_by_owner(owner_id, limit, offset).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let assets = self.inner.find_by_owner(owner_id, limit, offset).await?;
|
||||||
|
self.filter_visible(assets).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
filters: &AssetFilters,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
if owner_id == &self.caller_id {
|
||||||
|
return self.inner.search(owner_id, filters, limit, offset).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let assets = self.inner.search(owner_id, filters, limit, offset).await?;
|
||||||
|
self.filter_visible(assets).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_by_owner(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||||
|
self.inner.count_by_owner(owner_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_search(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
filters: &AssetFilters,
|
||||||
|
) -> Result<u64, DomainError> {
|
||||||
|
self.inner.count_search(owner_id, filters).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn date_summary(
|
||||||
|
&self,
|
||||||
|
owner_id: &SystemId,
|
||||||
|
) -> Result<Vec<(chrono::NaiveDate, u64)>, DomainError> {
|
||||||
|
self.inner.date_summary(owner_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save(&self, asset: &Asset) -> Result<(), DomainError> {
|
||||||
|
self.inner.save(asset).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
self.inner.delete(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn soft_delete(&self, id: &SystemId, deleted_by: &SystemId) -> Result<(), DomainError> {
|
||||||
|
self.inner.soft_delete(id, deleted_by).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn restore(&self, id: &SystemId) -> Result<(), DomainError> {
|
||||||
|
self.inner.restore(id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_trashed_before(&self, cutoff: chrono::DateTime<chrono::Utc>) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
self.inner.find_trashed_before(cutoff).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count_trashed(&self, owner_id: &SystemId) -> Result<u64, DomainError> {
|
||||||
|
self.inner.count_trashed(owner_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_trashed_by_owner(&self, owner_id: &SystemId, limit: u32, offset: u32) -> Result<Vec<Asset>, DomainError> {
|
||||||
|
self.inner.find_trashed_by_owner(owner_id, limit, offset).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::testing::{InMemoryAssetRepository, InMemoryShareRepository};
|
||||||
|
use domain::{
|
||||||
|
catalog::entities::{AssetType, SourceReference},
|
||||||
|
sharing::entities::{ScopeType, ShareScope, ShareTarget, ShareableType, TargetType},
|
||||||
|
value_objects::{Checksum, SystemId},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn make_asset(owner: SystemId) -> Asset {
|
||||||
|
Asset::new(
|
||||||
|
SourceReference {
|
||||||
|
volume_id: SystemId::new(),
|
||||||
|
relative_path: "test/photo.jpg".to_string(),
|
||||||
|
checksum: Checksum::new("a".repeat(64)).unwrap(),
|
||||||
|
},
|
||||||
|
AssetType::Image,
|
||||||
|
"image/jpeg",
|
||||||
|
1024,
|
||||||
|
owner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn share_asset(asset_id: SystemId, granter: SystemId) -> ShareScope {
|
||||||
|
ShareScope::new(ScopeType::User, ShareableType::Asset, asset_id, granter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn target_user(scope_id: SystemId, user_id: SystemId) -> ShareTarget {
|
||||||
|
ShareTarget::new(scope_id, TargetType::User, user_id, SystemId::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn owner_can_always_see_own_asset() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let asset = make_asset(owner_id);
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), owner_id);
|
||||||
|
|
||||||
|
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().asset_id, asset.asset_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stranger_cannot_see_unshared_asset() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let stranger_id = SystemId::new();
|
||||||
|
let asset = make_asset(owner_id);
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
||||||
|
|
||||||
|
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shared_user_can_see_asset() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let friend_id = SystemId::new();
|
||||||
|
let asset = make_asset(owner_id);
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
|
let scope = share_asset(asset.asset_id, owner_id);
|
||||||
|
share_repo.save_scope(&scope).await.unwrap();
|
||||||
|
|
||||||
|
let target = target_user(scope.scope_id, friend_id);
|
||||||
|
share_repo.save_target(&target).await.unwrap();
|
||||||
|
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), friend_id);
|
||||||
|
|
||||||
|
let found = filtered.find_by_id(&asset.asset_id).await.unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().asset_id, asset.asset_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_by_checksum_filters_inaccessible() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let stranger_id = SystemId::new();
|
||||||
|
|
||||||
|
let asset_a = make_asset(owner_id);
|
||||||
|
let mut asset_b = make_asset(stranger_id);
|
||||||
|
asset_b.source_reference.checksum = asset_a.source_reference.checksum.clone();
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset_a).await.unwrap();
|
||||||
|
inner.save(&asset_b).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
||||||
|
|
||||||
|
let results = filtered
|
||||||
|
.find_by_checksum(&asset_a.source_reference.checksum)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
assert_eq!(results[0].owner_user_id, stranger_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_by_owner_skips_filter_for_own_assets() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let asset = make_asset(owner_id);
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), owner_id);
|
||||||
|
|
||||||
|
let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap();
|
||||||
|
assert_eq!(results.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn find_by_owner_filters_others_assets() {
|
||||||
|
let owner_id = SystemId::new();
|
||||||
|
let stranger_id = SystemId::new();
|
||||||
|
let asset = make_asset(owner_id);
|
||||||
|
|
||||||
|
let inner = Arc::new(InMemoryAssetRepository::new());
|
||||||
|
inner.save(&asset).await.unwrap();
|
||||||
|
|
||||||
|
let share_repo = Arc::new(InMemoryShareRepository::new());
|
||||||
|
|
||||||
|
let filtered =
|
||||||
|
VisibilityFilteredAssetRepository::new(inner.clone(), share_repo.clone(), stranger_id);
|
||||||
|
|
||||||
|
let results = filtered.find_by_owner(&owner_id, 10, 0).await.unwrap();
|
||||||
|
assert!(results.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user