Compare commits
67 Commits
943a0abe54
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d221e60de | |||
| d282e8ea7e | |||
| c9715baab8 | |||
| 22b1dd3f56 | |||
| 813778dc7e | |||
| 063bab910b | |||
| 839cababe4 | |||
| fdf286ca94 | |||
| e30e785936 | |||
| 10be024bdf | |||
| 1e33c6d184 | |||
| 96168cfc7b | |||
| 95b839355f | |||
| 85285b2a52 | |||
| c3d8bcad29 | |||
| 8aa64ab174 | |||
| d84c54bb30 | |||
| 41bed3583d | |||
| 0a8b52514d | |||
| a0c0ba1c5d | |||
| 94cab1ea7c | |||
| 3dde0d13db | |||
| 1c81d7768c | |||
| 07df6fe207 | |||
| 1a7448fd3d | |||
| 587dcc04de | |||
| 498f3b1818 | |||
| 0ee2e04fe5 | |||
| 89045414cf | |||
| 2de6690401 | |||
| 12378c3649 | |||
| 46b8488b09 | |||
| 7e02f15a85 | |||
| d60c47199c | |||
| 2484f1e603 | |||
| 44d7df33a2 | |||
| 7cfa234902 | |||
| 3ee75305a9 | |||
| 322e9ee81a | |||
| 96ce5f7d26 | |||
| 6bf4ffc4ab | |||
| fa881c3fd1 | |||
| 0eb56c2be6 | |||
| c224cc6bd2 | |||
| 6a9b4e5c00 | |||
| 5e0dde656c | |||
| 5266646b0b | |||
| 2e32847129 | |||
| dee013c7eb | |||
| 12da356a40 | |||
| 26152660bb | |||
| c8f93bdd35 | |||
| 081a20ae31 | |||
| cde2f5aaae | |||
| f584bcd724 | |||
| 925f74bb3d | |||
| 306f4489fd | |||
| 5d4622e046 | |||
| 2e2adef5e0 | |||
| 206ad44e82 | |||
| 8eecd06fb8 | |||
| 29cc68b07c | |||
| 9794babe06 | |||
| d7fbf8d9e2 | |||
| 9f6ba55afc | |||
| 10c811d9a7 | |||
| 5ae38c834a |
@@ -45,7 +45,9 @@ ALLOW_REGISTRATION=true
|
||||
# PORT=3000
|
||||
# RATE_LIMIT=60
|
||||
# SECURE_COOKIES=true
|
||||
# RUST_LOG=presentation=info,tower_http=info,worker=info
|
||||
# Handler-level logs come from the `presentation` crate; startup/wiring logs come
|
||||
# from `server`. Include both — `server=info` alone silently drops handler logs.
|
||||
# RUST_LOG=server=info,presentation=info,tower_http=info,worker=info
|
||||
|
||||
# CORS (for SPA development only)
|
||||
# CORS_ORIGINS=http://localhost:5173
|
||||
|
||||
@@ -40,3 +40,8 @@ jobs:
|
||||
|
||||
- name: test
|
||||
run: cargo test
|
||||
|
||||
# Layering guards. These live in the Makefile and were previously local-only,
|
||||
# so a PR could go green in CI while violating them.
|
||||
- name: guards
|
||||
run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
|
||||
|
||||
9
.github/workflows/ci.yml
vendored
9
.github/workflows/ci.yml
vendored
@@ -41,6 +41,11 @@ jobs:
|
||||
- name: test
|
||||
run: cargo test
|
||||
|
||||
# Layering guards. These live in the Makefile and were previously local-only,
|
||||
# so a PR could go green in CI while violating them.
|
||||
- name: guards
|
||||
run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,8 +62,8 @@ jobs:
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,7 +13,8 @@
|
||||
|
||||
.worktrees/
|
||||
.superpowers/
|
||||
docs/
|
||||
docs/*
|
||||
!docs/adr/
|
||||
|
||||
imgs/
|
||||
.sqlx/
|
||||
65
CONTEXT.md
Normal file
65
CONTEXT.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# Movies Diary
|
||||
|
||||
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
|
||||
|
||||
## Language
|
||||
|
||||
**Movie**:
|
||||
A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it.
|
||||
_Avoid_: Film entry, title record
|
||||
|
||||
**Person**:
|
||||
Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only.
|
||||
_Avoid_: Celebrity, artist, talent
|
||||
|
||||
**Review**:
|
||||
A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
|
||||
_Avoid_: Diary entry, watch, log entry
|
||||
|
||||
**Rating**:
|
||||
A 1–5 whole-star score given to a movie in a Review. No half-stars, no zero.
|
||||
_Avoid_: Score, grade, stars (as a noun for the value itself)
|
||||
|
||||
**WatchMedium**:
|
||||
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
|
||||
_Avoid_: Source, format, venue, platform
|
||||
|
||||
**Watchlist**:
|
||||
A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed.
|
||||
_Avoid_: Queue, backlog, to-watch list
|
||||
|
||||
**Goal**:
|
||||
A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future.
|
||||
_Avoid_: Challenge, resolution, target
|
||||
|
||||
**WrapUp**:
|
||||
A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review."
|
||||
_Avoid_: Stats page, recap, summary
|
||||
|
||||
**User**:
|
||||
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
|
||||
_Avoid_: Account, member, profile (as a synonym for the whole User)
|
||||
|
||||
**SocialIdentity**:
|
||||
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
|
||||
_Avoid_: Actor, participant, social user
|
||||
|
||||
**Follow**:
|
||||
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
|
||||
_Avoid_: Subscribe, connect, friend
|
||||
|
||||
**Feed**:
|
||||
A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app.
|
||||
_Avoid_: Timeline, activity stream, home
|
||||
|
||||
**WatchEvent**:
|
||||
An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it.
|
||||
_Avoid_: Playback event, webhook event, auto-import
|
||||
|
||||
**Import**:
|
||||
Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch.
|
||||
_Avoid_: Upload, migration, sync
|
||||
|
||||
**ImportProfile**:
|
||||
A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users.
|
||||
_Avoid_: Template, mapping preset, import config
|
||||
@@ -10,7 +10,7 @@ Thanks for your interest in Movies Diary! This is a personal project but contrib
|
||||
4. Run the backend and worker:
|
||||
|
||||
```bash
|
||||
cargo run -p presentation # HTTP server on :3000
|
||||
cargo run -p server # HTTP server on :3000
|
||||
cargo run -p worker # event worker (separate terminal)
|
||||
```
|
||||
|
||||
@@ -44,7 +44,7 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture
|
||||
**Key rules:**
|
||||
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
|
||||
- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`)
|
||||
- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc<dyn Trait>` params to keep adapter dependencies explicit
|
||||
- Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `composition::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc<dyn Trait>` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate
|
||||
|
||||
```
|
||||
domain → pure types, traits (ports), zero deps
|
||||
|
||||
172
Cargo.lock
generated
172
Cargo.lock
generated
@@ -220,6 +220,17 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adapter-common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"domain",
|
||||
"sqlx",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
@@ -293,7 +304,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
name = "api-types"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"domain",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"utoipa",
|
||||
"uuid",
|
||||
]
|
||||
@@ -319,8 +332,8 @@ dependencies = [
|
||||
"domain",
|
||||
"futures",
|
||||
"hex",
|
||||
"infra-wiring",
|
||||
"rand 0.9.4",
|
||||
"reqwest 0.13.3",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
@@ -1106,6 +1119,39 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "composition"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"anyhow",
|
||||
"application",
|
||||
"async-trait",
|
||||
"auth",
|
||||
"chrono",
|
||||
"domain",
|
||||
"infra-wiring",
|
||||
"jellyfin",
|
||||
"metadata",
|
||||
"nats",
|
||||
"object-storage",
|
||||
"plex",
|
||||
"poster-fetcher",
|
||||
"postgres",
|
||||
"postgres-event-queue",
|
||||
"postgres-federation",
|
||||
"postgres-search",
|
||||
"postgres-social",
|
||||
"sqlite",
|
||||
"sqlite-event-queue",
|
||||
"sqlite-federation",
|
||||
"sqlite-search",
|
||||
"sqlite-social",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -1607,7 +1653,9 @@ dependencies = [
|
||||
"email_address",
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -2701,6 +2749,14 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infra-wiring"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"sqlx",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
@@ -2871,9 +2927,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "k-ap"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||
checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
|
||||
checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29"
|
||||
dependencies = [
|
||||
"activitypub_federation",
|
||||
"anyhow",
|
||||
@@ -2882,9 +2938,11 @@ dependencies = [
|
||||
"chrono",
|
||||
"enum_delegate",
|
||||
"futures",
|
||||
"paste",
|
||||
"reqwest 0.13.3",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -3874,6 +3932,7 @@ dependencies = [
|
||||
name = "postgres"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -3881,6 +3940,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"domain",
|
||||
"futures",
|
||||
"postgres-social",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -3909,11 +3969,13 @@ name = "postgres-federation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"adapter-common",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"k-ap",
|
||||
"postgres-social",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tracing",
|
||||
@@ -3924,12 +3986,25 @@ dependencies = [
|
||||
name = "postgres-search"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"sqlx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-social"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"sqlx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -3964,42 +4039,22 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
||||
name = "presentation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"anyhow",
|
||||
"api-types",
|
||||
"application",
|
||||
"async-trait",
|
||||
"auth",
|
||||
"axum",
|
||||
"axum-governor",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"composition",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"export",
|
||||
"futures",
|
||||
"http-body-util",
|
||||
"importer",
|
||||
"infer",
|
||||
"jellyfin",
|
||||
"metadata",
|
||||
"nats",
|
||||
"object-storage",
|
||||
"percent-encoding",
|
||||
"plex",
|
||||
"poster-fetcher",
|
||||
"postgres",
|
||||
"postgres-event-queue",
|
||||
"postgres-federation",
|
||||
"postgres-search",
|
||||
"rss 0.1.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlite",
|
||||
"sqlite-event-queue",
|
||||
"sqlite-federation",
|
||||
"sqlite-search",
|
||||
"sqlx",
|
||||
"template-askama",
|
||||
"tokio",
|
||||
"tower",
|
||||
@@ -4586,7 +4641,6 @@ dependencies = [
|
||||
name = "rss"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"domain",
|
||||
"rss 2.0.13",
|
||||
]
|
||||
@@ -4920,6 +4974,50 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"anyhow",
|
||||
"application",
|
||||
"async-trait",
|
||||
"auth",
|
||||
"axum",
|
||||
"bytes",
|
||||
"composition",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"export",
|
||||
"futures",
|
||||
"http-body-util",
|
||||
"importer",
|
||||
"infra-wiring",
|
||||
"metadata",
|
||||
"nats",
|
||||
"object-storage",
|
||||
"poster-fetcher",
|
||||
"postgres",
|
||||
"postgres-event-queue",
|
||||
"postgres-federation",
|
||||
"postgres-search",
|
||||
"postgres-social",
|
||||
"presentation",
|
||||
"rss 0.1.0",
|
||||
"serde_json",
|
||||
"sqlite",
|
||||
"sqlite-event-queue",
|
||||
"sqlite-federation",
|
||||
"sqlite-search",
|
||||
"sqlite-social",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
@@ -5153,6 +5251,7 @@ dependencies = [
|
||||
name = "sqlite"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -5162,6 +5261,7 @@ dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlite-social",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5188,12 +5288,14 @@ name = "sqlite-federation"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"activitypub",
|
||||
"adapter-common",
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"k-ap",
|
||||
"serde_json",
|
||||
"sqlite-social",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5204,6 +5306,7 @@ dependencies = [
|
||||
name = "sqlite-search"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"async-trait",
|
||||
"domain",
|
||||
"sqlx",
|
||||
@@ -5211,6 +5314,19 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-social"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"adapter-common",
|
||||
"async-trait",
|
||||
"chrono",
|
||||
"domain",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlx"
|
||||
version = "0.8.6"
|
||||
@@ -5595,7 +5711,7 @@ dependencies = [
|
||||
name = "template-askama"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"application",
|
||||
"api-types",
|
||||
"askama",
|
||||
"chrono",
|
||||
"domain",
|
||||
@@ -6685,7 +6801,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7120,11 +7236,13 @@ dependencies = [
|
||||
"application",
|
||||
"async-trait",
|
||||
"auth",
|
||||
"composition",
|
||||
"domain",
|
||||
"dotenvy",
|
||||
"export",
|
||||
"image-converter",
|
||||
"importer",
|
||||
"infra-wiring",
|
||||
"metadata",
|
||||
"nats",
|
||||
"object-storage",
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -10,7 +10,9 @@ members = [
|
||||
"crates/adapters/sqlite",
|
||||
"crates/adapters/postgres",
|
||||
"crates/adapters/sqlite-federation",
|
||||
"crates/adapters/sqlite-social",
|
||||
"crates/adapters/postgres-federation",
|
||||
"crates/adapters/postgres-social",
|
||||
"crates/adapters/sqlite-event-queue",
|
||||
"crates/adapters/postgres-event-queue",
|
||||
"crates/adapters/template-askama",
|
||||
@@ -23,7 +25,9 @@ members = [
|
||||
"crates/adapters/tmdb-enrichment",
|
||||
"crates/adapters/image-converter",
|
||||
"crates/domain",
|
||||
"crates/composition",
|
||||
"crates/presentation",
|
||||
"crates/server",
|
||||
"crates/tui",
|
||||
"crates/worker",
|
||||
"crates/adapters/importer",
|
||||
@@ -31,6 +35,8 @@ members = [
|
||||
"crates/adapters/plex",
|
||||
"crates/adapters/sqlite-search",
|
||||
"crates/adapters/postgres-search",
|
||||
"crates/adapters/adapter-common",
|
||||
"crates/infra-wiring",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
@@ -66,6 +72,7 @@ api-types = { path = "crates/api-types" }
|
||||
domain = { path = "crates/domain" }
|
||||
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
|
||||
application = { path = "crates/application" }
|
||||
composition = { path = "crates/composition" }
|
||||
presentation = { path = "crates/presentation" }
|
||||
auth = { path = "crates/adapters/auth" }
|
||||
metadata = { path = "crates/adapters/metadata" }
|
||||
@@ -77,8 +84,10 @@ rss = { path = "crates/adapters/rss" }
|
||||
export = { path = "crates/adapters/export" }
|
||||
sqlite = { path = "crates/adapters/sqlite" }
|
||||
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
||||
sqlite-social = { path = "crates/adapters/sqlite-social" }
|
||||
postgres = { path = "crates/adapters/postgres" }
|
||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||
postgres-social = { path = "crates/adapters/postgres-social" }
|
||||
template-askama = { path = "crates/adapters/template-askama" }
|
||||
activitypub = { path = "crates/adapters/activitypub" }
|
||||
event-payload = { path = "crates/adapters/event-payload" }
|
||||
@@ -91,10 +100,11 @@ plex = { path = "crates/adapters/plex" }
|
||||
image-converter = { path = "crates/adapters/image-converter" }
|
||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||
infra-wiring = { path = "crates/infra-wiring" }
|
||||
|
||||
[profile.dev]
|
||||
debug = 1 # line tables only — still debuggable, much faster linking
|
||||
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -12,6 +12,10 @@ FROM rust:slim-bookworm AS builder
|
||||
WORKDIR /build
|
||||
|
||||
# Cache dependency compilation separately from source
|
||||
#
|
||||
# Every workspace member's Cargo.toml must be listed here by hand — `cargo fetch`
|
||||
# below reads the whole workspace graph, so a missing manifest fails the build with
|
||||
# "failed to read .../Cargo.toml". Adding a crate to crates/ means adding a line here.
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY .cargo ./.cargo
|
||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
||||
@@ -30,21 +34,27 @@ COPY crates/adapters/plex/Cargo.toml crates/adapters/plex/Cargo.tom
|
||||
COPY crates/adapters/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
|
||||
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
|
||||
COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml
|
||||
COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml
|
||||
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
|
||||
COPY crates/adapters/postgres-social/Cargo.toml crates/adapters/postgres-social/Cargo.toml
|
||||
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
|
||||
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
|
||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml
|
||||
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
|
||||
COPY crates/composition/Cargo.toml crates/composition/Cargo.toml
|
||||
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
|
||||
COPY crates/server/Cargo.toml crates/server/Cargo.toml
|
||||
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
|
||||
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
|
||||
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
|
||||
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
|
||||
COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml
|
||||
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
|
||||
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml
|
||||
|
||||
# Stub every crate so cargo can resolve and fetch deps
|
||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
||||
@@ -69,7 +79,7 @@ COPY crates ./crates
|
||||
# To add NATS support (EVENT_BUS_BACKEND=nats):
|
||||
# --build-arg FEATURES=sqlite,sqlite-federation,nats
|
||||
ARG FEATURES=sqlite,sqlite-federation
|
||||
RUN cargo build --release -p presentation -p worker --no-default-features --features "${FEATURES}"
|
||||
RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
|
||||
|
||||
# ----- runtime -----
|
||||
FROM debian:bookworm-slim
|
||||
@@ -83,13 +93,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /build/target/release/presentation ./presentation
|
||||
COPY --from=builder /build/target/release/server ./server
|
||||
COPY --from=builder /build/target/release/worker ./worker
|
||||
COPY static ./static
|
||||
COPY --from=spa-builder /spa/dist ./spa/dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV RUST_LOG=presentation=info,tower_http=info
|
||||
ENV RUST_LOG=server=info,tower_http=info
|
||||
|
||||
CMD ["./presentation"]
|
||||
CMD ["./server"]
|
||||
|
||||
160
Makefile
160
Makefile
@@ -1,7 +1,7 @@
|
||||
.DEFAULT_GOAL := check
|
||||
|
||||
# Run the full local check suite — same order as CI would.
|
||||
check: fmt-check clippy test check-appcontext
|
||||
check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
|
||||
@echo "✅ All checks passed"
|
||||
|
||||
# Enforce that no application use case imports AppContext (god-object guard).
|
||||
@@ -13,6 +13,162 @@ check-appcontext:
|
||||
fi
|
||||
@echo "✅ No AppContext in application crate"
|
||||
|
||||
# Enforce that presentation never hand-builds a deps struct (composition-root guard).
|
||||
# Catches struct literals (`FooDeps { ... }`) and conversions (`FooDeps::from(...)`) —
|
||||
# both are the composition-root pattern that belongs in crates/composition.
|
||||
#
|
||||
# Scope is the whole crate, not just handlers/, so that factoring a
|
||||
# `fn build_login_deps(state) -> LoginDeps { .. }` helper into a sibling module
|
||||
# does not slip past the guard. One exclusion:
|
||||
# src/tests/ — test fixtures legitimately assemble their own state
|
||||
# `src/main.rs` used to be excluded too ("the binary IS the wiring root") before
|
||||
# crates/server took over the binary (ADR-0006) — presentation is lib-only now, has no
|
||||
# main.rs, and the exclusion was dropped rather than left pointing at a file that no
|
||||
# longer exists.
|
||||
# NOTE: `\s` is a GNU grep extension, not POSIX ERE. Fine on GNU/Ubuntu runners;
|
||||
# would need `[[:space:]]` if this ever runs under BusyBox/Alpine grep.
|
||||
check-handler-deps:
|
||||
@if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
|
||||
echo "❌ hand-built deps struct found in presentation:"; \
|
||||
grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✅ No hand-built deps structs in presentation"
|
||||
|
||||
# Enforce that presentation calls use cases only, never repository methods directly
|
||||
# (ADR-0006's guard; ADR-0007 records the widening below).
|
||||
#
|
||||
# Was: two shape-matching grep passes for `repos.<field>.<method>(` and
|
||||
# `repos.<field>.clone().<method>(`, needed because `AppContext.repos` was still a
|
||||
# legitimate way to reach a repository — ~40 sites cloned an Arc out of it to pass
|
||||
# positionally into a use case, so the guard had to distinguish that legal shape
|
||||
# from an illegal direct call. That shape-matching was demonstrably incomplete: it
|
||||
# matches `repos.<field>.<method>(` textually, so it cannot see a bare reference
|
||||
# pass (`&repos.diary`, no call at all at the read site) or a `let`-bound access
|
||||
# (`if let Some(x) = repos.federated_profile` then `x.method()` on the *binding*,
|
||||
# not on `repos` itself). Both shapes existed in production code — Plan C2's Task 6
|
||||
# review caught it, Task 7 fixed both — while the two-pass guard printed a clean
|
||||
# ✅ the whole time. ADR-0006's "handlers call use cases only" was false at that
|
||||
# line for as long as the hole existed.
|
||||
#
|
||||
# Now: Plan C2 deleted `AppContext.repos` entirely (ADR-0007), so there is no
|
||||
# longer any legitimate reason for the substring `app_ctx.repos` to appear in
|
||||
# production code — not a call, not a clone, not a reference, not a let-binding.
|
||||
# That makes the rule trivial and unevadeable: any mention at all is an offender.
|
||||
# No shape to match means no shape to miss.
|
||||
#
|
||||
# One exclusion: src/tests/ — test helpers still legitimately build a
|
||||
# `Repositories` and call `composition::build_deps` to produce test state.
|
||||
check-handler-repos:
|
||||
@if grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
|
||||
echo "❌ app_ctx.repos referenced in presentation:"; \
|
||||
grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✅ No app_ctx.repos references in presentation"
|
||||
|
||||
# Enforce that presentation depends on at most one adapter crate.
|
||||
#
|
||||
# Presentation may name things that RENDER OUTPUT; it may not name things that
|
||||
# REACH external systems or storage. `template-askama` is the HTML template
|
||||
# engine, so it stays. See ADR-0008.
|
||||
#
|
||||
# This checks Cargo.toml rather than grepping src/, deliberately: `<crate>::`
|
||||
# greps collide with same-named modules inside `application` and `presentation`
|
||||
# (`auth::logout` is `application::auth`; `rss::get_user_feed` is
|
||||
# `handlers::rss`), so the source-level grep cannot decide this. Every directory
|
||||
# under crates/adapters/ is named exactly like its package, so directory names
|
||||
# are a safe source of truth.
|
||||
PRESENTATION_ALLOWED_ADAPTERS := template-askama
|
||||
|
||||
check-presentation-adapters:
|
||||
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/Cargo.toml \
|
||||
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
|
||||
bad=""; \
|
||||
for a in $$(ls crates/adapters); do \
|
||||
case " $(PRESENTATION_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \
|
||||
if echo "$$deps" | grep -qx "$$a"; then bad="$$bad $$a"; fi; \
|
||||
done; \
|
||||
if [ -n "$$bad" ]; then \
|
||||
echo "❌ presentation depends on adapter crate(s):$$bad"; \
|
||||
echo " presentation may name renderers, not reachers — see ADR-0008."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)"
|
||||
|
||||
# Enforce that the federation-off build wires real social behavior, not silent noops.
|
||||
#
|
||||
# With federation off, `NoopSocialCommand::follow` returns Ok(()) and writes nothing,
|
||||
# and `NoopSocialQuery` always answers zero/empty. The write side is currently
|
||||
# unreachable over HTTP (the social routes are federation-gated in presentation), but
|
||||
# the query side is not: `get_local_profile`, `get_page_viewer` and `get_activity_feed`
|
||||
# read it through fully ungated routes, so a noop there means a federation-off
|
||||
# instance lies about its own follow graph on every profile view and activity feed
|
||||
# request — contradicting ADR-0003, which established that local follows bypass
|
||||
# ActivityPub entirely. `application::social::LocalSocialService` is what must be
|
||||
# wired instead. See ADR-0009.
|
||||
#
|
||||
# Two checks, not one: absence of the noops is necessary but not sufficient — deleting
|
||||
# the whole `#[cfg(not(feature = "federation"))]` wiring block would also make the
|
||||
# first check pass, leaving federation-off with nothing wired at all, which is worse
|
||||
# than the noops. So this also asserts `LocalSocialService` is present — and it must
|
||||
# be present in the federation-OFF region specifically, not merely anywhere in the
|
||||
# file: `LocalSocialService` is also constructed inside the federation-ON branch
|
||||
# (feeding `CompositeSocialAdapter`), so a bare file-wide grep is satisfied by that
|
||||
# occurrence alone and would not catch the federation-off block being deleted. The
|
||||
# awk carves out the region from the first `#[cfg(not(feature = "federation"))]` to
|
||||
# the next `#[cfg(feature = "federation")]` — the same kind of range
|
||||
# `check-presentation-adapters` uses for `[dependencies]` — and greps only inside it.
|
||||
# Not an occurrence count: that would work today but break the moment the two
|
||||
# `LocalSocialService` constructions in `main.rs` are deduplicated (a recorded
|
||||
# follow-up), which could legitimately drop the total to one.
|
||||
#
|
||||
# A grep rather than a test because the decision lives in `wire_dependencies()`, a
|
||||
# private fn in a binary crate, unreachable from integration tests.
|
||||
#
|
||||
# Scope is the single file `crates/server/src/main.rs`, not the whole crate: it is the
|
||||
# sole wiring site for these ports. `worker` and `tui` never name SocialCommand /
|
||||
# FollowGraphQuery / BlockQuery at all, so there is nowhere else for the noops to
|
||||
# reappear. Relocating the wiring out of main.rs would move it out of the guard's
|
||||
# scope — widen this to `crates/server/src` if that ever happens.
|
||||
check-federation-off-social:
|
||||
@if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs | grep -q .; then \
|
||||
echo "❌ server wires social noops — federation-off would report an empty follow graph:"; \
|
||||
grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs; \
|
||||
exit 1; \
|
||||
fi
|
||||
@if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/server/src/main.rs | grep -q "LocalSocialService"; then \
|
||||
echo "❌ LocalSocialService not wired in the federation-off region of crates/server/src/main.rs — federation-off has no social behavior wired at all, not even noops"; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✅ server wires real social behavior in every configuration"
|
||||
|
||||
# Enforce that `<backend>-social` crates never depend on ActivityPub.
|
||||
#
|
||||
# ADR-0009's whole federation-optional property rests on one invariant: a
|
||||
# `<backend>-social` crate speaks only `domain::ports`; a `<backend>-federation` crate
|
||||
# speaks ActivityPub; the former must never depend on the latter. It holds today, and
|
||||
# nothing but this guard enforces it. One manifest line —
|
||||
# `k-ap = { version = "0.5.0", registry = "gitea" }` in a `-social` crate's Cargo.toml,
|
||||
# the obvious move when someone wants to reuse a helper like `status_to_str` from
|
||||
# `sqlite-federation/src/lib.rs` — would silently restore all 76 crates and 19MB that
|
||||
# federation-off exists to shed, while `make check`, `cargo test` and CI all stay green
|
||||
# without this guard to catch it. Matches `activitypub` with any suffix, so
|
||||
# `activitypub_federation = "0.6"` (the upstream crate carrying all 76 transitives) is
|
||||
# caught too, not just the local `activitypub` crate.
|
||||
#
|
||||
# Known gap: a dependency renamed via a `package = "k-ap"` key (`foo = { package =
|
||||
# "k-ap", ... }`), or declared with a `[dependencies.k-ap]` table header instead of the
|
||||
# inline form, would not match this line-level regex. Not attempted — nothing in the
|
||||
# workspace does either today.
|
||||
check-social-crates-are-ap-free:
|
||||
@if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml | grep -q .; then \
|
||||
echo "❌ social crate depends on ActivityPub:"; \
|
||||
grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "✅ social crates depend on no k-ap, activitypub, or *-federation crate"
|
||||
|
||||
# Apply rustfmt to all files.
|
||||
fmt:
|
||||
cargo fmt
|
||||
@@ -34,4 +190,4 @@ fix:
|
||||
cargo fmt
|
||||
cargo clippy --fix --allow-dirty --allow-staged
|
||||
|
||||
.PHONY: check fmt fmt-check clippy test fix
|
||||
.PHONY: check fmt fmt-check clippy test fix check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
|
||||
|
||||
39
README.md
39
README.md
@@ -47,21 +47,22 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
||||
|
||||
## Features
|
||||
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
||||
- Immutable append-only viewing ledger (tracks re-watches)
|
||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 1–5 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
|
||||
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
|
||||
- Background poster fetching and storage (local filesystem or S3-compatible)
|
||||
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
|
||||
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
|
||||
- People as first-class entities — browse by person via `GET /api/v1/people/{id}` and full credit history via `GET /api/v1/people/{id}/credits`; index populated automatically during TMDb enrichment
|
||||
- RSS/Atom feed for public subscription (global and per-user)
|
||||
- JWT authentication via cookie (HTML) or Bearer token (REST API)
|
||||
- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox, boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable)
|
||||
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` activity, delivery filter excludes blocked actors and blocked-domain inboxes
|
||||
- ActivityPub federation — follow/unfollow remote users, accept/reject/remove followers, federated reviews broadcast as `Note` objects with movie poster image attachment, `#MoviesDiary` + `#MovieTitle` hashtags, paginated outbox (reviews, watchlist entries, goals), boost/Announce tracking, NodeInfo discovery endpoint, shared inbox delivery, actor profile sync (bio, avatar, discoverable); account migration via `Move` activity; account deletion broadcasts `Delete` actor to followers
|
||||
- Federation moderation — instance-level domain blocking (admin-managed), per-user actor blocking with `Block` / `Undo Block` activities, delivery filter excludes blocked actors and blocked-domain inboxes
|
||||
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
|
||||
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API
|
||||
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
|
||||
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
|
||||
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
|
||||
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
|
||||
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
|
||||
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
|
||||
- CSV and JSON diary export
|
||||
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
|
||||
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
|
||||
@@ -88,21 +89,24 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
|
||||
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||
|
||||
```
|
||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
|
||||
domain — pure types and trait definitions, no external deps
|
||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
|
||||
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
|
||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
||||
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries
|
||||
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
|
||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
|
||||
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving; library crate, no binary of its own
|
||||
server — owns the HTTP binary, backend-selection features (sqlite/postgres, federation), boots presentation's router
|
||||
worker — standalone worker binary (event consumer, poster sync, federation)
|
||||
adapters/
|
||||
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
|
||||
auth — JWT issuance and validation (Argon2 passwords)
|
||||
sqlite — SQLite repository + connection factory
|
||||
postgres — PostgreSQL repository + connection factory
|
||||
metadata — TMDB / OMDb HTTP client
|
||||
poster-fetcher — downloads poster images
|
||||
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
||||
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
|
||||
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
|
||||
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
|
||||
tmdb-enrichment — event handler: fetches full movie profile (cast, crew, genres, keywords, box office) from TMDb on MovieEnrichmentRequested; resolves IMDb IDs automatically
|
||||
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
|
||||
template-askama — Askama HTML rendering
|
||||
rss — RSS/Atom feed generation
|
||||
export — CSV and JSON diary serialization
|
||||
@@ -112,6 +116,7 @@ adapters/
|
||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
||||
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
||||
event-publisher — in-memory event channel (used in tests)
|
||||
nats — NATS Core / JetStream event publisher and consumer
|
||||
event-publisher — in-memory event channel (used in tests)
|
||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
||||
@@ -155,12 +160,12 @@ Copy `.env.example` to `.env` and set the values below. Required fields must be
|
||||
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
|
||||
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
|
||||
| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
|
||||
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
|
||||
| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`) |
|
||||
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
|
||||
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
|
||||
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
|
||||
|
||||
The `worker` binary must run alongside `presentation` to process events:
|
||||
The `worker` binary must run alongside `server` to process events:
|
||||
|
||||
```bash
|
||||
cargo run -p worker
|
||||
@@ -169,11 +174,11 @@ cargo run -p worker
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cargo run -p presentation # HTTP server (0.0.0.0:3000)
|
||||
cargo run -p server # HTTP server (0.0.0.0:3000)
|
||||
cargo run -p worker # event worker (poster sync, in a separate terminal)
|
||||
```
|
||||
|
||||
The worker polls the event queue and must run alongside the presentation to process background tasks like poster fetching. Both processes share the same database.
|
||||
The worker polls the event queue and must run alongside the server to process background tasks like poster fetching. Both processes share the same database.
|
||||
|
||||
## API
|
||||
|
||||
@@ -245,7 +250,7 @@ This builds and starts the HTTP server (port 3000) and event worker. Data is per
|
||||
|
||||
### Manual docker run
|
||||
|
||||
The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume:
|
||||
The image contains both `server` and `worker` binaries. Run them as separate containers sharing the same data volume:
|
||||
|
||||
```bash
|
||||
docker build -t movies-diary .
|
||||
|
||||
@@ -14,29 +14,35 @@ graph TB
|
||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
||||
subgraph UseCases["Use Cases"]
|
||||
UC_AUTH["auth<br/>login, register"]
|
||||
UC_DIARY["diary<br/>log_review, get_diary,<br/>get_activity_feed, export"]
|
||||
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search"]
|
||||
UC_DIARY["diary<br/>log_review, edit_review,<br/>get_diary, get_activity_feed,<br/>export"]
|
||||
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
|
||||
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
|
||||
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile"]
|
||||
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
|
||||
UC_WATCHLIST["watchlist<br/>add, remove, get"]
|
||||
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
|
||||
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
|
||||
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
||||
UC_SEARCH["search<br/>execute"]
|
||||
UC_PERSON["person<br/>get, get_credits"]
|
||||
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
|
||||
end
|
||||
subgraph EventHandlers["Event Handlers"]
|
||||
EH_ENRICH["EnrichmentHandler"]
|
||||
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
|
||||
EH_PERSON["PersonEnrichmentHandler<br/><i>on PersonEnrichmentRequested</i>"]
|
||||
EH_DISCOVER["MovieDiscoveryIndexer"]
|
||||
EH_CLEANUP["SearchCleanupHandler"]
|
||||
EH_REINDEX["SearchReindexHandler"]
|
||||
EH_WRAPUP["WrapUpEventHandler"]
|
||||
EH_AP["ActivityPubEventHandler<br/><i>reviews, watchlist, goals,<br/>UserDeleted, UserAccountMoved</i>"]
|
||||
end
|
||||
subgraph Jobs["Periodic Jobs"]
|
||||
JOB_IMPORT["ImportSessionCleanup"]
|
||||
JOB_WATCH["WatchEventCleanup"]
|
||||
JOB_STALE["EnrichmentStaleness"]
|
||||
JOB_WRAPGEN["WrapUpAutoGenerate"]
|
||||
JOB_WRAPCLEAN["WrapUpCleanup"]
|
||||
JOB_SESSION["RefreshSessionCleanup"]
|
||||
JOB_DEDUP["MovieDeduplication<br/><i>merge duplicate movie<br/>records (daily)</i>"]
|
||||
end
|
||||
WORKER_SVC["WorkerService<br/><i>Semaphore(8), JoinSet,<br/>shutdown signal</i>"]
|
||||
end
|
||||
@@ -45,7 +51,7 @@ graph TB
|
||||
direction TB
|
||||
subgraph Models["Models"]
|
||||
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
||||
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
|
||||
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
|
||||
M_USER["User, UserSummary"]
|
||||
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
||||
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
||||
@@ -54,25 +60,30 @@ graph TB
|
||||
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
||||
end
|
||||
subgraph Ports["Port Traits (Interfaces)"]
|
||||
P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository"]
|
||||
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
|
||||
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
|
||||
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
|
||||
P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
|
||||
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
|
||||
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||
end
|
||||
subgraph DomainServices["Services (pure, no I/O)"]
|
||||
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
||||
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
||||
end
|
||||
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>SearchReindexRequested, ...</i>"]
|
||||
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, Password, ...</i>"]
|
||||
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
|
||||
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
|
||||
end
|
||||
|
||||
subgraph ApiTypes["api-types (0 domain deps)"]
|
||||
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"]
|
||||
subgraph ApiTypes["api-types"]
|
||||
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>HtmlPageContext, ...</i>"]
|
||||
end
|
||||
|
||||
subgraph InfraWiring["infra-wiring"]
|
||||
IW["DbPool, EventBusBackend,<br/>AppConfig<br/><i>Shared infra types</i>"]
|
||||
end
|
||||
|
||||
subgraph Adapters["Adapters (implement Port Traits)"]
|
||||
direction TB
|
||||
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
|
||||
subgraph Storage["Storage"]
|
||||
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
|
||||
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
|
||||
@@ -82,7 +93,9 @@ graph TB
|
||||
end
|
||||
subgraph Messaging["Messaging"]
|
||||
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
||||
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
|
||||
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
|
||||
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
||||
end
|
||||
subgraph External["External Services"]
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
domain = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
@@ -21,12 +20,34 @@ impl ApContentReader for CompositeObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<DateTime<Utc>>,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
||||
self.review
|
||||
.get_local_objects_page(user_id, before, limit)
|
||||
.await
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let fetch_limit = limit * 3;
|
||||
let reviews = self
|
||||
.review
|
||||
.get_local_objects_page(user_id, before, fetch_limit)
|
||||
.await?;
|
||||
let watchlist = self
|
||||
.watchlist
|
||||
.get_local_objects_page(user_id, None, usize::MAX)
|
||||
.await?;
|
||||
let goals = self
|
||||
.goal
|
||||
.get_local_objects_page(user_id, None, usize::MAX)
|
||||
.await?;
|
||||
|
||||
let mut all: Vec<LocalObject> = Vec::new();
|
||||
all.extend(reviews);
|
||||
all.extend(watchlist);
|
||||
all.extend(goals);
|
||||
|
||||
if let Some(before_ts) = before {
|
||||
all.retain(|obj| obj.published_at < before_ts);
|
||||
}
|
||||
all.sort_by_key(|obj| std::cmp::Reverse(obj.published_at));
|
||||
all.truncate(limit);
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
@@ -42,10 +63,14 @@ impl ApObjectHandler for CompositeObjectHandler {
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|
||||
|| object.get("rating").is_some();
|
||||
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true)
|
||||
|| (object.get("movieTitle").is_some() && object.get("rating").is_none());
|
||||
|| (object.get("movieTitle").is_some()
|
||||
&& object.get("rating").is_none()
|
||||
&& object.get("review").is_none());
|
||||
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
|
||||
if object.get("rating").is_some() {
|
||||
if is_review {
|
||||
self.review.on_create(ap_id, actor_url, object).await
|
||||
} else if is_goal {
|
||||
self.goal.on_create(ap_id, actor_url, object).await
|
||||
@@ -63,11 +88,16 @@ impl ApObjectHandler for CompositeObjectHandler {
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let is_review = object.get("review").and_then(|v| v.as_bool()) == Some(true)
|
||||
|| object.get("rating").is_some();
|
||||
let is_goal = object.get("goal").and_then(|v| v.as_bool()) == Some(true);
|
||||
if object.get("rating").is_some() {
|
||||
let is_watchlist = object.get("watchlistEntry").and_then(|v| v.as_bool()) == Some(true);
|
||||
if is_review {
|
||||
self.review.on_update(ap_id, actor_url, object).await
|
||||
} else if is_goal {
|
||||
self.goal.on_update(ap_id, actor_url, object).await
|
||||
} else if is_watchlist {
|
||||
self.watchlist.on_update(ap_id, actor_url, object).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,36 +117,19 @@ impl ApObjectHandler for CompositeObjectHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_received(
|
||||
&self,
|
||||
_object_url: &Url,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_of_remote(
|
||||
&self,
|
||||
_object_url: &Url,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_mention(
|
||||
&self,
|
||||
_thought_ap_id: &Url,
|
||||
_mentioned_user_uuid: uuid::Uuid,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,35 +4,51 @@ use domain::ports::EventHandler;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{LocalApContentQuery, UserFederationSettingsQuery},
|
||||
value_objects::{MovieId, ReviewId, UserId},
|
||||
ports::{
|
||||
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
||||
UserFederationSettingsQuery,
|
||||
},
|
||||
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use k_ap::{ActivityPubService, ApVisibility};
|
||||
|
||||
use crate::objects::{goal_to_ap_object, review_to_ap_object};
|
||||
use crate::objects::{ReviewApInput, goal_to_ap_object, review_to_ap_object};
|
||||
use crate::urls::{actor_url, goal_url, review_url};
|
||||
|
||||
pub struct ActivityPubEventHandler {
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieQuery>,
|
||||
goal_repo: Arc<dyn GoalQuery>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl ActivityPubEventHandler {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
content_query: Arc<dyn LocalApContentQuery>,
|
||||
review_repo: Arc<dyn ReviewRepository>,
|
||||
movie_repo: Arc<dyn MovieQuery>,
|
||||
goal_repo: Arc<dyn GoalQuery>,
|
||||
stats_repo: Arc<dyn StatsRepository>,
|
||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||
base_url: String,
|
||||
instance: InstanceIdentity,
|
||||
) -> Self {
|
||||
Self {
|
||||
ap_service,
|
||||
content_query,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
instance,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,12 +108,8 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
let inbox: url::Url = inbox_url
|
||||
.parse()
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
|
||||
let activity: serde_json::Value =
|
||||
serde_json::from_str(activity_json).map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("bad activity JSON: {e}"))
|
||||
})?;
|
||||
self.ap_service
|
||||
.deliver_to_inbox(inbox, activity, *signing_actor_id)
|
||||
.deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
@@ -127,6 +139,25 @@ impl EventHandler for ActivityPubEventHandler {
|
||||
.on_goal_deleted(user_id, *year)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||
DomainEvent::UserDeleted { user_id } => {
|
||||
let ap_id = actor_url(&self.instance, user_id.value());
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
DomainEvent::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => {
|
||||
let target = new_actor_url.parse::<url::Url>().map_err(|e| {
|
||||
DomainError::InfrastructureError(format!("invalid new_actor_url: {e}"))
|
||||
})?;
|
||||
self.ap_service
|
||||
.broadcast_move(user_id.value(), target)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -138,25 +169,21 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let ap_id = review_url(&self.instance, review_id);
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -169,24 +196,28 @@ impl ActivityPubEventHandler {
|
||||
.as_ref()
|
||||
.map(|m| m.release_year().value())
|
||||
.unwrap_or(0);
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ap_id.clone(),
|
||||
actor,
|
||||
ReviewApInput {
|
||||
ap_id: ap_id.clone(),
|
||||
actor_url: actor,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
external_metadata_id: movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.external_metadata_id())
|
||||
.map(|id| id.value().to_string()),
|
||||
poster_url: movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| self.instance.image_url_for(p.value())),
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
},
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
|
||||
let year = review.watched_at().year() as u16;
|
||||
@@ -204,25 +235,21 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let review = match self.content_query.get_review_by_id(review_id).await? {
|
||||
let review = match self.review_repo.get_review_by_id(review_id).await? {
|
||||
Some(r) => r,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let ap_id = review_url(&self.instance, review_id);
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
|
||||
let movie = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(review.movie_id())
|
||||
.await
|
||||
.ok()
|
||||
@@ -235,24 +262,28 @@ impl ActivityPubEventHandler {
|
||||
.as_ref()
|
||||
.map(|m| m.release_year().value())
|
||||
.unwrap_or(0);
|
||||
let poster_url = movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
ReviewApInput {
|
||||
ap_id,
|
||||
actor,
|
||||
actor_url: actor,
|
||||
movie_title,
|
||||
release_year,
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
external_metadata_id: movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.external_metadata_id())
|
||||
.map(|id| id.value().to_string()),
|
||||
poster_url: movie
|
||||
.as_ref()
|
||||
.and_then(|m| m.poster_path())
|
||||
.map(|p| self.instance.image_url_for(p.value())),
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
},
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
@@ -263,7 +294,7 @@ impl ActivityPubEventHandler {
|
||||
user_id: &UserId,
|
||||
review_id: &ReviewId,
|
||||
) -> anyhow::Result<()> {
|
||||
let ap_id = review_url(&self.base_url, review_id);
|
||||
let ap_id = review_url(&self.instance, review_id);
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
@@ -283,28 +314,24 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.watchlist {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
use crate::urls::watchlist_entry_url;
|
||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
|
||||
let poster_url = self
|
||||
.content_query
|
||||
.movie_repo
|
||||
.get_movie_by_id(movie_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|m| {
|
||||
m.poster_path()
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
|
||||
.map(|p| self.instance.image_url_for(p.value()))
|
||||
});
|
||||
|
||||
let added_at_utc =
|
||||
@@ -317,12 +344,12 @@ impl ActivityPubEventHandler {
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
poster_url,
|
||||
added_at: added_at_utc,
|
||||
base_url: self.base_url.clone(),
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
});
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -333,7 +360,7 @@ impl ActivityPubEventHandler {
|
||||
movie_id: &domain::value_objects::MovieId,
|
||||
) -> anyhow::Result<()> {
|
||||
use crate::urls::watchlist_entry_url;
|
||||
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
|
||||
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
@@ -346,14 +373,17 @@ impl ActivityPubEventHandler {
|
||||
.get_local_reviews_for_movie(movie_id)
|
||||
.await?;
|
||||
|
||||
let movie = self.content_query.get_movie_by_id(movie_id).await?;
|
||||
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
|
||||
let movie = match movie {
|
||||
Some(m) => m,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let external_metadata_id = movie
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string());
|
||||
let poster_url = movie
|
||||
.poster_path()
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
.map(|p| self.instance.image_url_for(p.value()));
|
||||
|
||||
for entry in entries {
|
||||
let review = entry.review();
|
||||
@@ -363,31 +393,30 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.reviews {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let ap_id = review_url(&self.instance, review.id());
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
ReviewApInput {
|
||||
ap_id,
|
||||
actor,
|
||||
movie.title().value().to_string(),
|
||||
movie.release_year().value(),
|
||||
poster_url.clone(),
|
||||
&self.base_url,
|
||||
actor_url: actor,
|
||||
movie_title: movie.title().value().to_string(),
|
||||
release_year: movie.release_year().value(),
|
||||
external_metadata_id: external_metadata_id.clone(),
|
||||
poster_url: poster_url.clone(),
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
},
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -403,36 +432,37 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let Some((goal, current)) = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
let Some(goal) = self
|
||||
.goal_repo
|
||||
.find_by_user_and_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let current = self
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let ap_id = goal_url(&self.instance, user_id.value(), year);
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
let obj = goal_to_ap_object(
|
||||
ap_id,
|
||||
actor,
|
||||
year,
|
||||
goal.target_count(),
|
||||
current,
|
||||
&self.base_url,
|
||||
self.instance.base_url(),
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -448,34 +478,34 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let current = self
|
||||
.content_query
|
||||
.get_goal_with_progress(user_id, year)
|
||||
.stats_repo
|
||||
.count_reviews_in_year(user_id, year)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(_, c)| c)
|
||||
.unwrap_or(0);
|
||||
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let actor = actor_url(&self.base_url, user_id.value());
|
||||
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
|
||||
let ap_id = goal_url(&self.instance, user_id.value(), year);
|
||||
let actor = actor_url(&self.instance, user_id.value());
|
||||
let obj = goal_to_ap_object(
|
||||
ap_id,
|
||||
actor,
|
||||
year,
|
||||
target_count,
|
||||
current,
|
||||
self.instance.base_url(),
|
||||
);
|
||||
let json = serde_json::to_value(obj)?;
|
||||
if is_create {
|
||||
self.ap_service
|
||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
} else {
|
||||
self.ap_service
|
||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
@@ -486,15 +516,11 @@ impl ActivityPubEventHandler {
|
||||
.federation_settings
|
||||
.get_federation_flags(user_id)
|
||||
.await
|
||||
.unwrap_or(domain::models::FederationFlags {
|
||||
goals: true,
|
||||
reviews: true,
|
||||
watchlist: true,
|
||||
});
|
||||
.unwrap_or_default();
|
||||
if !flags.goals {
|
||||
return Ok(());
|
||||
}
|
||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
||||
let ap_id = goal_url(&self.instance, user_id.value(), year);
|
||||
self.ap_service
|
||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||
.await?;
|
||||
|
||||
@@ -33,22 +33,41 @@ impl k_ap::EventPublisher for FederationEventBridge {
|
||||
inbox,
|
||||
activity,
|
||||
signing_actor_id,
|
||||
} => {
|
||||
let json = serde_json::to_string(&activity)
|
||||
.map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?;
|
||||
self.domain_publisher
|
||||
} => self
|
||||
.domain_publisher
|
||||
.publish(&DomainEvent::FederationDeliveryRequested {
|
||||
inbox_url: inbox.to_string(),
|
||||
activity_json: json,
|
||||
activity_json: activity,
|
||||
signing_actor_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
}
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string())),
|
||||
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
||||
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
|
||||
Ok(())
|
||||
}
|
||||
FederationEvent::OutboundFollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
} => {
|
||||
let identity = domain::value_objects::SocialIdentity::Remote {
|
||||
actor_url: remote_actor_url,
|
||||
};
|
||||
self.domain_publisher
|
||||
.publish(&DomainEvent::FollowAccepted {
|
||||
owner: UserId::from_uuid(local_user_id),
|
||||
requester: identity,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
if let Some(outbox) = outbox_url {
|
||||
tracing::info!(outbox = %outbox, "importing remote outbox after follow accepted");
|
||||
// Handled by FollowBackfillHandler reacting to FollowAccepted
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
137
crates/adapters/activitypub/src/federation_ports.rs
Normal file
137
crates/adapters/activitypub/src/federation_ports.rs
Normal file
@@ -0,0 +1,137 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{BlockedDomainInfo, FollowedActorInfo},
|
||||
ports::{ApBackfillPort, ApDocumentPort, InstanceBlocklistPort},
|
||||
};
|
||||
use k_ap::ActivityPubService;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Adapts the federation library's service to the three domain-owned ports.
|
||||
///
|
||||
/// A wrapper rather than a bare `impl ... for ActivityPubService` because the
|
||||
/// orphan rule forbids implementing a foreign trait for a foreign type, and
|
||||
/// from this crate both `ApDocumentPort` (owned by `domain`) and
|
||||
/// `ActivityPubService` (owned by the external `k-ap` crate) are foreign.
|
||||
///
|
||||
/// One type carrying all three impls mirrors `CompositeSocialAdapter`, which
|
||||
/// serves `SocialCommand`, `FollowGraphQuery`, and `BlockQuery` the same way.
|
||||
pub struct ApServiceAdapter {
|
||||
service: Arc<ActivityPubService>,
|
||||
}
|
||||
|
||||
impl ApServiceAdapter {
|
||||
pub fn new(service: Arc<ActivityPubService>) -> Self {
|
||||
Self { service }
|
||||
}
|
||||
}
|
||||
|
||||
/// Single conversion point from the federation library's `anyhow` errors to
|
||||
/// `DomainError`. The log line and the resulting error string reproduce what
|
||||
/// `presentation::handlers::social::ap_to_domain` produced before the port
|
||||
/// inversion moved the boundary here.
|
||||
fn ap_err(e: anyhow::Error) -> DomainError {
|
||||
tracing::error!("ActivityPub error: {:?}", e);
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApDocumentPort for ApServiceAdapter {
|
||||
async fn actor_json(&self, user_id: &str) -> Result<String, DomainError> {
|
||||
self.service.actor_json(user_id).await.map_err(ap_err)
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
self.service
|
||||
.followers_collection_json(user_id, page)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> Result<String, DomainError> {
|
||||
self.service
|
||||
.following_collection_json(user_id, page)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InstanceBlocklistPort for ApServiceAdapter {
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomainInfo>, DomainError> {
|
||||
let domains = self.service.get_blocked_domains().await.map_err(ap_err)?;
|
||||
Ok(domains
|
||||
.into_iter()
|
||||
.map(|d| BlockedDomainInfo {
|
||||
domain: d.domain,
|
||||
reason: d.reason,
|
||||
blocked_at: d.blocked_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
async fn add_blocked_domain(
|
||||
&self,
|
||||
domain: &str,
|
||||
reason: Option<&str>,
|
||||
) -> Result<(), DomainError> {
|
||||
self.service
|
||||
.add_blocked_domain(domain, reason)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<(), DomainError> {
|
||||
self.service
|
||||
.remove_blocked_domain(domain)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApBackfillPort for ApServiceAdapter {
|
||||
async fn get_following(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
) -> Result<Vec<FollowedActorInfo>, DomainError> {
|
||||
let actors = self
|
||||
.service
|
||||
.get_following(local_user_id)
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| FollowedActorInfo {
|
||||
url: a.url,
|
||||
outbox_url: a.outbox_url,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
async fn import_remote_outbox(
|
||||
&self,
|
||||
outbox_url: &str,
|
||||
actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
self.service
|
||||
.import_remote_outbox(outbox_url, actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> Result<(), DomainError> {
|
||||
self.service
|
||||
.run_backfill_for_follower(owner_user_id, follower_inbox_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,69 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository};
|
||||
use k_ap::ApObjectHandler;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
models::RemoteGoalEntry,
|
||||
ports::{GoalQuery, RemoteGoalRepository},
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::GoalObject;
|
||||
use crate::objects::{GoalObject, goal_to_ap_object};
|
||||
use crate::urls::{actor_url, goal_url};
|
||||
|
||||
pub struct GoalObjectHandler {
|
||||
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
|
||||
pub goal_repo: Arc<dyn GoalQuery>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for GoalObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
_before: Option<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let goals = self
|
||||
.goal_repo
|
||||
.list_for_user(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let actor = actor_url(&self.instance, user_id);
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
let mut results = Vec::new();
|
||||
for goal in goals {
|
||||
let ap_id = goal_url(&self.instance, user_id, goal.year());
|
||||
let published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc);
|
||||
let obj = goal_to_ap_object(
|
||||
ap_id.clone(),
|
||||
actor.clone(),
|
||||
goal.year(),
|
||||
goal.target_count(),
|
||||
0,
|
||||
self.instance.base_url(),
|
||||
);
|
||||
results.push(LocalObject {
|
||||
ap_id,
|
||||
object: serde_json::to_value(obj)?,
|
||||
published_at: published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![follower_cc.clone()],
|
||||
bto: vec![],
|
||||
bcc: vec![],
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
pub mod composite_handler;
|
||||
pub mod event_handler;
|
||||
pub mod federation_event_bridge;
|
||||
pub mod federation_ports;
|
||||
pub mod goal_handler;
|
||||
pub mod objects;
|
||||
pub mod port;
|
||||
pub mod remote_review_repository;
|
||||
pub mod review_handler;
|
||||
pub mod social_adapter;
|
||||
pub(crate) mod urls;
|
||||
pub mod user_adapter;
|
||||
pub mod watchlist_handler;
|
||||
@@ -17,27 +18,39 @@ pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
||||
pub use k_ap::{
|
||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
||||
};
|
||||
|
||||
pub use event_handler::ActivityPubEventHandler;
|
||||
pub use port::{ActivityPubPort, NoopActivityPubService};
|
||||
pub use remote_review_repository::RemoteReviewRepository;
|
||||
pub use federation_ports::ApServiceAdapter;
|
||||
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
pub use review_handler::ReviewObjectHandler;
|
||||
pub use social_adapter::CompositeSocialAdapter;
|
||||
pub use user_adapter::DomainUserRepoAdapter;
|
||||
|
||||
pub type FederationRepos = (
|
||||
std::sync::Arc<dyn ActivityRepository>,
|
||||
std::sync::Arc<dyn FollowRepository>,
|
||||
std::sync::Arc<dyn ActorRepository>,
|
||||
std::sync::Arc<dyn BlocklistRepository>,
|
||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
||||
std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
);
|
||||
pub struct FederationRepos {
|
||||
pub activity: std::sync::Arc<dyn ActivityRepository>,
|
||||
pub follow: std::sync::Arc<dyn FollowRepository>,
|
||||
pub actor: std::sync::Arc<dyn ActorRepository>,
|
||||
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
|
||||
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
||||
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||
pub remote_watchlist: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
||||
}
|
||||
|
||||
pub struct ActivityPubWire {
|
||||
pub service: std::sync::Arc<dyn ActivityPubPort>,
|
||||
/// AP document serving. Prefer this over `service` from outside this crate.
|
||||
pub document: std::sync::Arc<dyn domain::ports::ApDocumentPort>,
|
||||
/// Instance domain blocklist. Prefer this over `service` from outside this crate.
|
||||
pub blocklist: std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
|
||||
/// Post-follow content backfill. Prefer this over `service` from outside this crate.
|
||||
pub backfill: std::sync::Arc<dyn domain::ports::ApBackfillPort>,
|
||||
/// The concrete service, consumed by `crates/server` to construct
|
||||
/// `CompositeSocialAdapter`. Everything else should use
|
||||
/// `document`/`blocklist`/`backfill`.
|
||||
pub service: std::sync::Arc<ActivityPubService>,
|
||||
pub router: axum::Router,
|
||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
||||
}
|
||||
@@ -51,9 +64,16 @@ pub struct ActivityPubDeps {
|
||||
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieQuery>,
|
||||
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryQuery>,
|
||||
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalQuery>,
|
||||
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub base_url: String,
|
||||
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
||||
pub instance: domain::value_objects::InstanceIdentity,
|
||||
pub allow_registration: bool,
|
||||
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
||||
}
|
||||
@@ -68,23 +88,37 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
remote_watchlist_repo,
|
||||
remote_goal_repo,
|
||||
local_ap_content,
|
||||
movie_repo,
|
||||
review_repo,
|
||||
diary_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
user_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
follow_command: _,
|
||||
follow_query: _,
|
||||
instance,
|
||||
allow_registration,
|
||||
event_publisher,
|
||||
} = deps;
|
||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||
diary_repo,
|
||||
review_store,
|
||||
base_url: base_url.clone(),
|
||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||
instance: instance.clone(),
|
||||
});
|
||||
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||
remote_watchlist_repo,
|
||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||
base_url: base_url.clone(),
|
||||
instance: instance.clone(),
|
||||
});
|
||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
||||
remote_goal_repo,
|
||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
||||
instance: instance.clone(),
|
||||
});
|
||||
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler { remote_goal_repo });
|
||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||
review: review_handler,
|
||||
watchlist: watchlist_handler,
|
||||
@@ -107,14 +141,14 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
);
|
||||
|
||||
let concrete = std::sync::Arc::new(
|
||||
ActivityPubService::builder(base_url.clone())
|
||||
ActivityPubService::builder(instance.base_url().to_string())
|
||||
.activity_repo(activity_repo)
|
||||
.follow_repo(follow_repo)
|
||||
.actor_repo(actor_repo)
|
||||
.blocklist_repo(blocklist_repo)
|
||||
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
||||
user_repo,
|
||||
base_url.clone(),
|
||||
instance.clone(),
|
||||
)))
|
||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
|
||||
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
|
||||
@@ -122,6 +156,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
.event_publisher(fed_event_bridge)
|
||||
.allow_registration(allow_registration)
|
||||
.software_name("movies-diary")
|
||||
.nodeinfo_metadata(serde_json::json!({
|
||||
"nodeName": "movies-diary",
|
||||
"nodeDescription": "A federated movie diary"
|
||||
}))
|
||||
.debug(federation_debug)
|
||||
.build()
|
||||
.await?,
|
||||
@@ -131,12 +169,25 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
local_ap_content,
|
||||
review_repo,
|
||||
movie_repo,
|
||||
goal_repo,
|
||||
stats_repo,
|
||||
federation_settings,
|
||||
base_url,
|
||||
instance,
|
||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
||||
|
||||
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
|
||||
std::sync::Arc::clone(&concrete),
|
||||
));
|
||||
|
||||
Ok(ActivityPubWire {
|
||||
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
|
||||
document: std::sync::Arc::clone(&ports)
|
||||
as std::sync::Arc<dyn domain::ports::ApDocumentPort>,
|
||||
blocklist: std::sync::Arc::clone(&ports)
|
||||
as std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
|
||||
backfill: ports as std::sync::Arc<dyn domain::ports::ApBackfillPort>,
|
||||
service: concrete,
|
||||
router,
|
||||
event_handler,
|
||||
})
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use k_ap::AS_PUBLIC;
|
||||
use k_ap::NoteType;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use domain::models::Review;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
pub(crate) enum ActivityStreamsType {
|
||||
#[default]
|
||||
Note,
|
||||
Article,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ApAttachment {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
pub(crate) url: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) media_type: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ApHashtag {
|
||||
#[serde(rename = "type")]
|
||||
@@ -22,7 +40,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReviewObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -31,10 +49,19 @@ pub struct ReviewObject {
|
||||
#[serde(default)]
|
||||
pub(crate) release_year: u16,
|
||||
#[serde(default)]
|
||||
pub(crate) external_metadata_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) poster_url: Option<String>,
|
||||
pub(crate) rating: u8,
|
||||
pub(crate) comment: Option<String>,
|
||||
pub(crate) watched_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub(crate) watch_medium: Option<String>,
|
||||
/// Discriminator so Movies Diary instances detect this as a review Note.
|
||||
#[serde(default)]
|
||||
pub(crate) review: bool,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) attachment: Vec<ApAttachment>,
|
||||
#[serde(default)]
|
||||
pub(crate) tag: Vec<ApHashtag>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
@@ -43,17 +70,26 @@ pub struct ReviewObject {
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
/// Serialize a local Review into a ReviewObject for AP delivery.
|
||||
/// Takes movie metadata explicitly since the handler fetches it separately.
|
||||
pub fn review_to_ap_object(
|
||||
review: &Review,
|
||||
ap_id: Url,
|
||||
actor_url: Url,
|
||||
movie_title: String,
|
||||
release_year: u16,
|
||||
poster_url: Option<String>,
|
||||
base_url: &str,
|
||||
) -> ReviewObject {
|
||||
pub struct ReviewApInput {
|
||||
pub ap_id: Url,
|
||||
pub actor_url: Url,
|
||||
pub movie_title: String,
|
||||
pub release_year: u16,
|
||||
pub external_metadata_id: Option<String>,
|
||||
pub poster_url: Option<String>,
|
||||
pub base_url: String,
|
||||
}
|
||||
|
||||
pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObject {
|
||||
let ReviewApInput {
|
||||
ap_id,
|
||||
actor_url,
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
poster_url,
|
||||
base_url,
|
||||
} = input;
|
||||
let stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
||||
let comment_text = review.comment().map(|c| c.value().to_string());
|
||||
let year_str = if release_year > 0 {
|
||||
@@ -84,19 +120,32 @@ pub fn review_to_ap_object(
|
||||
name: format!("#{}", normalized),
|
||||
},
|
||||
];
|
||||
let attachment = match &poster_url {
|
||||
Some(url) => vec![ApAttachment {
|
||||
kind: "Image".to_string(),
|
||||
url: url.clone(),
|
||||
media_type: Some("image/jpeg".to_string()),
|
||||
name: Some(movie_title.clone()),
|
||||
}],
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
ReviewObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
||||
movie_title,
|
||||
release_year,
|
||||
external_metadata_id,
|
||||
poster_url,
|
||||
rating: review.rating().value(),
|
||||
comment: comment_text,
|
||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
|
||||
review: true,
|
||||
attachment,
|
||||
tag,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![format!("{}/followers", actor_url)],
|
||||
@@ -107,7 +156,7 @@ pub fn review_to_ap_object(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WatchlistObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -175,7 +224,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
||||
];
|
||||
|
||||
WatchlistObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
@@ -197,7 +246,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GoalObject {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: NoteType,
|
||||
pub(crate) kind: ActivityStreamsType,
|
||||
pub(crate) id: Url,
|
||||
pub(crate) attributed_to: Url,
|
||||
pub(crate) content: String,
|
||||
@@ -234,7 +283,7 @@ pub fn goal_to_ap_object(
|
||||
}];
|
||||
|
||||
GoalObject {
|
||||
kind: NoteType::default(),
|
||||
kind: ActivityStreamsType::default(),
|
||||
id: ap_id,
|
||||
attributed_to: actor_url.clone(),
|
||||
content,
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
use async_trait::async_trait;
|
||||
use uuid::Uuid;
|
||||
|
||||
use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
|
||||
|
||||
#[async_trait]
|
||||
pub trait ActivityPubPort: Send + Sync {
|
||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
|
||||
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
|
||||
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
|
||||
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn accept_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()>;
|
||||
async fn reject_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()>;
|
||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn get_accepted_followers(&self, local_user_id: Uuid)
|
||||
-> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()>;
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()>;
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>>;
|
||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()>;
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String>;
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String>;
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubPort for ActivityPubService {
|
||||
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
|
||||
self.actor_json(user_id).await
|
||||
}
|
||||
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||
self.count_following(local_user_id).await
|
||||
}
|
||||
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
|
||||
self.count_accepted_followers(local_user_id).await
|
||||
}
|
||||
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_pending_followers(local_user_id).await
|
||||
}
|
||||
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
|
||||
self.follow(local_user_id, handle).await
|
||||
}
|
||||
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.unfollow(local_user_id, actor_url).await
|
||||
}
|
||||
async fn accept_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
self.accept_follower(local_user_id, remote_actor_url).await
|
||||
}
|
||||
async fn reject_follower(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
self.reject_follower(local_user_id, remote_actor_url).await
|
||||
}
|
||||
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_following(local_user_id).await
|
||||
}
|
||||
async fn get_accepted_followers(
|
||||
&self,
|
||||
local_user_id: Uuid,
|
||||
) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_accepted_followers(local_user_id).await
|
||||
}
|
||||
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.remove_follower(local_user_id, actor_url).await
|
||||
}
|
||||
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.block_actor(local_user_id, actor_url).await
|
||||
}
|
||||
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.unblock_actor(local_user_id, actor_url).await
|
||||
}
|
||||
async fn get_blocked_actors(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
self.get_blocked_actors(local_user_id).await
|
||||
}
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> anyhow::Result<()> {
|
||||
self.add_blocked_domain(domain, reason).await
|
||||
}
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> anyhow::Result<()> {
|
||||
self.remove_blocked_domain(domain).await
|
||||
}
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
self.get_blocked_domains().await
|
||||
}
|
||||
async fn import_remote_outbox(&self, outbox_url: &str, actor_url: &str) -> anyhow::Result<()> {
|
||||
self.import_remote_outbox(outbox_url, actor_url).await
|
||||
}
|
||||
async fn followers_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String> {
|
||||
self.followers_collection_json(user_id, page).await
|
||||
}
|
||||
async fn following_collection_json(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
page: Option<u32>,
|
||||
) -> anyhow::Result<String> {
|
||||
self.following_collection_json(user_id, page).await
|
||||
}
|
||||
async fn run_backfill_for_follower(
|
||||
&self,
|
||||
owner_user_id: Uuid,
|
||||
follower_inbox_url: String,
|
||||
) -> anyhow::Result<()> {
|
||||
self.run_backfill_for_follower(owner_user_id, follower_inbox_url)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NoopActivityPubService;
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityPubPort for NoopActivityPubService {
|
||||
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
|
||||
Ok(0)
|
||||
}
|
||||
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn unfollow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn accept_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn reject_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn block_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn unblock_actor(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_actors(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn add_blocked_domain(&self, _: &str, _: Option<&str>) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_blocked_domain(&self, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_domains(&self) -> anyhow::Result<Vec<BlockedDomain>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn import_remote_outbox(&self, _: &str, _: &str) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn followers_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn following_collection_json(&self, _: Uuid, _: Option<u32>) -> anyhow::Result<String> {
|
||||
Ok(String::new())
|
||||
}
|
||||
async fn run_backfill_for_follower(&self, _: Uuid, _: String) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,16 @@ use async_trait::async_trait;
|
||||
use chrono::NaiveDateTime;
|
||||
use domain::models::Review;
|
||||
|
||||
pub struct RemoteReviewUpdate<'a> {
|
||||
pub ap_id: &'a str,
|
||||
pub actor_url: &'a str,
|
||||
pub rating: u8,
|
||||
pub comment: Option<&'a str>,
|
||||
pub watched_at: NaiveDateTime,
|
||||
pub poster_url: Option<&'a str>,
|
||||
pub watch_medium: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RemoteReviewRepository: Send + Sync {
|
||||
async fn save_remote_review(
|
||||
@@ -11,20 +21,13 @@ pub trait RemoteReviewRepository: Send + Sync {
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()>;
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||
|
||||
async fn update_remote_review(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
watched_at: NaiveDateTime,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()>;
|
||||
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
@@ -2,21 +2,27 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
events::DomainEvent,
|
||||
models::ReviewSource,
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
|
||||
},
|
||||
};
|
||||
use k_ap::{ApContentReader, ApObjectHandler};
|
||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::{ReviewObject, review_to_ap_object};
|
||||
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
|
||||
use crate::remote_review_repository::RemoteReviewRepository;
|
||||
use crate::urls::{actor_url, review_url};
|
||||
|
||||
pub struct ReviewObjectHandler {
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub movie_repo: Arc<dyn MovieQuery>,
|
||||
pub diary_repo: Arc<dyn DiaryQuery>,
|
||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||
pub base_url: String,
|
||||
pub event_publisher: Arc<dyn EventPublisher>,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -26,7 +32,7 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
user_id: uuid::Uuid,
|
||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let domain_user_id = UserId::from_uuid(user_id);
|
||||
let before_naive = before.map(|dt| dt.naive_utc());
|
||||
let entries = self
|
||||
@@ -35,34 +41,48 @@ impl ApContentReader for ReviewObjectHandler {
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let actor = actor_url(&self.base_url, user_id);
|
||||
let actor = actor_url(&self.instance, user_id);
|
||||
let mut results = Vec::new();
|
||||
for entry in entries {
|
||||
let review = entry.review();
|
||||
let published =
|
||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||
let movie = entry.movie();
|
||||
let ap_id = review_url(&self.base_url, review.id());
|
||||
let ap_id = review_url(&self.instance, review.id());
|
||||
let poster_url = movie
|
||||
.poster_path()
|
||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
||||
.map(|p| self.instance.image_url_for(p.value()));
|
||||
|
||||
let obj = review_to_ap_object(
|
||||
review,
|
||||
ap_id.clone(),
|
||||
actor.clone(),
|
||||
movie.title().value().to_string(),
|
||||
movie.release_year().value(),
|
||||
ReviewApInput {
|
||||
ap_id: ap_id.clone(),
|
||||
actor_url: actor.clone(),
|
||||
movie_title: movie.title().value().to_string(),
|
||||
release_year: movie.release_year().value(),
|
||||
external_metadata_id: movie
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string()),
|
||||
poster_url,
|
||||
&self.base_url,
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
},
|
||||
);
|
||||
results.push((ap_id, serde_json::to_value(obj)?, published));
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
results.push(LocalObject {
|
||||
ap_id,
|
||||
object: serde_json::to_value(obj)?,
|
||||
published_at: published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![follower_cc],
|
||||
bto: vec![],
|
||||
bcc: vec![],
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
self.content_query
|
||||
self.diary_repo
|
||||
.count_local_posts()
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
||||
@@ -89,20 +109,45 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
|
||||
let actor_url_str = obj.attributed_to.to_string();
|
||||
let review_id = ReviewId::generate();
|
||||
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
|
||||
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
|
||||
self.movie_repo
|
||||
.get_movie_by_external_id(&ext_meta_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match found {
|
||||
Some(movie) => movie.id().clone(),
|
||||
None => MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
obj.movie_title.as_bytes(),
|
||||
));
|
||||
ext_id.as_bytes(),
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
MovieId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
format!("{}:{}", obj.movie_title, obj.release_year).as_bytes(),
|
||||
))
|
||||
};
|
||||
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
|
||||
&uuid::Uuid::NAMESPACE_URL,
|
||||
actor_url_str.as_bytes(),
|
||||
));
|
||||
let rating = Rating::new(obj.rating.min(5))?;
|
||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
||||
let watch_medium = obj
|
||||
.watch_medium
|
||||
.as_deref()
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.unwrap_or(None);
|
||||
|
||||
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
||||
id: review_id,
|
||||
movie_id,
|
||||
movie_id: movie_id.clone(),
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
@@ -111,6 +156,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
source: ReviewSource::Remote {
|
||||
actor_url: actor_url_str,
|
||||
},
|
||||
watch_medium,
|
||||
});
|
||||
|
||||
self.review_store
|
||||
@@ -119,10 +165,23 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
obj.id.as_str(),
|
||||
&obj.movie_title,
|
||||
obj.release_year,
|
||||
obj.external_metadata_id.as_deref(),
|
||||
obj.poster_url.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if let Some(ref ext_id_str) = obj.external_metadata_id
|
||||
&& let Ok(external_metadata_id) = ExternalMetadataId::new(ext_id_str.clone())
|
||||
{
|
||||
let _ = self
|
||||
.event_publisher
|
||||
.publish(&DomainEvent::MovieEnrichmentRequested {
|
||||
movie_id: movie_id.clone(),
|
||||
external_metadata_id,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -147,14 +206,15 @@ impl ApObjectHandler for ReviewObjectHandler {
|
||||
}
|
||||
|
||||
self.review_store
|
||||
.update_remote_review(
|
||||
ap_id.as_str(),
|
||||
actor_url.as_str(),
|
||||
obj.rating.min(5),
|
||||
obj.comment.as_deref(),
|
||||
obj.watched_at.naive_utc(),
|
||||
obj.poster_url.as_deref(),
|
||||
)
|
||||
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
|
||||
ap_id: ap_id.as_str(),
|
||||
actor_url: actor_url.as_str(),
|
||||
rating: obj.rating.min(5),
|
||||
comment: obj.comment.as_deref(),
|
||||
watched_at: obj.watched_at.naive_utc(),
|
||||
poster_url: obj.poster_url.as_deref(),
|
||||
watch_medium: obj.watch_medium.as_deref(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
207
crates/adapters/activitypub/src/social_adapter.rs
Normal file
207
crates/adapters/activitypub/src/social_adapter.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
|
||||
value_objects::{
|
||||
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
|
||||
},
|
||||
};
|
||||
|
||||
use k_ap::ActivityPubService;
|
||||
|
||||
pub struct CompositeSocialAdapter {
|
||||
local: Arc<dyn LocalSocial>,
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl CompositeSocialAdapter {
|
||||
pub fn new(
|
||||
local: Arc<dyn LocalSocial>,
|
||||
ap_service: Arc<ActivityPubService>,
|
||||
user_repo: Arc<dyn UserRepository>,
|
||||
instance: InstanceIdentity,
|
||||
) -> Self {
|
||||
Self {
|
||||
local,
|
||||
ap_service,
|
||||
user_repo,
|
||||
instance,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ap_err(e: anyhow::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SocialCommand for CompositeSocialAdapter {
|
||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||
let identity = self.local.resolve_target(target).await?;
|
||||
|
||||
if let SocialIdentity::Local(_) = identity {
|
||||
return self.local.follow_resolved(follower, &identity).await;
|
||||
}
|
||||
|
||||
let handle = match target {
|
||||
FollowTarget::Handle(h) => h.clone(),
|
||||
FollowTarget::Identity(id) => match id {
|
||||
SocialIdentity::Local(uid) => {
|
||||
let user = self
|
||||
.user_repo
|
||||
.find_by_id(uid)
|
||||
.await?
|
||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||
self.instance.handle_for(user.username().value())
|
||||
}
|
||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||
},
|
||||
};
|
||||
self.ap_service
|
||||
.follow(follower.value(), &handle)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn unfollow(
|
||||
&self,
|
||||
follower: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
match target {
|
||||
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn accept_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject_follow(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
requester: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
match requester {
|
||||
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
owner: &UserId,
|
||||
follower: &SocialIdentity,
|
||||
) -> Result<(), DomainError> {
|
||||
match follower {
|
||||
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
|
||||
SocialIdentity::Remote { .. } => self
|
||||
.ap_service
|
||||
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||
.await
|
||||
.map_err(ap_err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.ap_service
|
||||
.block_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
|
||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||
let actor_url = self.instance.actor_url_of(target);
|
||||
self.ap_service
|
||||
.unblock_actor(blocker.value(), &actor_url)
|
||||
.await
|
||||
.map_err(ap_err)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowGraphQuery for CompositeSocialAdapter {
|
||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.local.get_following(user).await
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.local.get_followers(user).await
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.local.get_pending_followers(user).await
|
||||
}
|
||||
|
||||
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
self.local.get_pending_following(user).await
|
||||
}
|
||||
|
||||
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.local.count_following(user).await
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.local.count_followers(user).await
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||
self.local.count_pending_followers(user).await
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer: &UserId,
|
||||
target: &SocialIdentity,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
self.local.get_relation(viewer, target).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlockQuery for CompositeSocialAdapter {
|
||||
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let actors = self
|
||||
.ap_service
|
||||
.get_blocked_actors(user.value())
|
||||
.await
|
||||
.map_err(ap_err)?;
|
||||
Ok(actors
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let identity = self.instance.identify(&a.url);
|
||||
SocialActor {
|
||||
identity,
|
||||
handle: a.handle,
|
||||
display_name: a.display_name,
|
||||
avatar_url: a.avatar_url,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
@@ -29,15 +29,19 @@ fn review_to_ap_object_includes_two_hashtags() {
|
||||
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
});
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
"https://example.com/reviews/1".parse().unwrap(),
|
||||
"https://example.com/users/1".parse().unwrap(),
|
||||
"Dune".to_string(),
|
||||
2021,
|
||||
None,
|
||||
"https://example.com",
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.tag.len(), 2);
|
||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
||||
@@ -64,16 +68,20 @@ fn review_to_ap_object_has_public_addressing() {
|
||||
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
source: ReviewSource::Local,
|
||||
watch_medium: None,
|
||||
});
|
||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||
let obj = review_to_ap_object(
|
||||
&review,
|
||||
"https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url.clone(),
|
||||
"Dune".to_string(),
|
||||
2021,
|
||||
None,
|
||||
"https://example.com",
|
||||
ReviewApInput {
|
||||
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||
actor_url: actor_url.clone(),
|
||||
movie_title: "Dune".to_string(),
|
||||
release_year: 2021,
|
||||
external_metadata_id: None,
|
||||
poster_url: None,
|
||||
base_url: "https://example.com".to_string(),
|
||||
},
|
||||
);
|
||||
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
||||
|
||||
@@ -1,28 +1,43 @@
|
||||
use domain::value_objects::ReviewId;
|
||||
use domain::value_objects::{InstanceIdentity, ReviewId, UserId};
|
||||
use url::Url;
|
||||
|
||||
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
|
||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
||||
pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url {
|
||||
Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id)))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
|
||||
pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
||||
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
|
||||
pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url {
|
||||
Url::parse(&format!(
|
||||
"{}/reviews/{}",
|
||||
instance.base_url(),
|
||||
review_id.value()
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
pub fn goal_url(base_url: &str, user_id: uuid::Uuid, year: u16) -> Url {
|
||||
Url::parse(&format!("{}/users/{}/goals/{}", base_url, user_id, year))
|
||||
pub fn goal_url(instance: &InstanceIdentity, user_id: uuid::Uuid, year: u16) -> Url {
|
||||
Url::parse(&format!(
|
||||
"{}/users/{}/goals/{}",
|
||||
instance.base_url(),
|
||||
user_id,
|
||||
year
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
/// Builds the canonical watchlist entry URL: `{base_url}/users/{user_id}/watchlist/{movie_id}`
|
||||
pub fn watchlist_entry_url(base_url: &str, user_id: uuid::Uuid, movie_id: uuid::Uuid) -> Url {
|
||||
pub fn watchlist_entry_url(
|
||||
instance: &InstanceIdentity,
|
||||
user_id: uuid::Uuid,
|
||||
movie_id: uuid::Uuid,
|
||||
) -> Url {
|
||||
Url::parse(&format!(
|
||||
"{}/users/{}/watchlist/{}",
|
||||
base_url, user_id, movie_id
|
||||
instance.base_url(),
|
||||
user_id,
|
||||
movie_id
|
||||
))
|
||||
.expect("base_url is always a valid URL prefix")
|
||||
}
|
||||
|
||||
@@ -1,28 +1,36 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{ports::UserRepository, value_objects::UserId};
|
||||
use domain::{
|
||||
ports::UserRepository,
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
use k_ap::{ApProfileField, ApUser, ApUserRepository};
|
||||
use url::Url;
|
||||
|
||||
pub struct DomainUserRepoAdapter {
|
||||
pub repo: Arc<dyn UserRepository>,
|
||||
pub base_url: String,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
impl DomainUserRepoAdapter {
|
||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
||||
Self { repo, base_url }
|
||||
pub fn new(repo: Arc<dyn UserRepository>, instance: InstanceIdentity) -> Self {
|
||||
Self { repo, instance }
|
||||
}
|
||||
|
||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
||||
let avatar_url = u
|
||||
.avatar_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
|
||||
let banner_url = u
|
||||
.banner_path()
|
||||
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
|
||||
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
||||
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
|
||||
let profile_url = Url::parse(&format!(
|
||||
"{}/u/{}",
|
||||
self.instance.base_url(),
|
||||
u.username().value()
|
||||
))
|
||||
.ok();
|
||||
ApUser {
|
||||
id: u.id().value(),
|
||||
username: u.username().value().to_string(),
|
||||
@@ -46,11 +54,7 @@ impl DomainUserRepoAdapter {
|
||||
manually_approves_followers: true,
|
||||
discoverable: true,
|
||||
actor_type: Default::default(),
|
||||
featured_url: Url::parse(&format!(
|
||||
"{}/users/{}/featured",
|
||||
self.base_url,
|
||||
u.id().value()
|
||||
))
|
||||
featured_url: Url::parse(&format!("{}/featured", self.instance.actor_url_for(u.id())))
|
||||
.ok(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,76 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::DateTime;
|
||||
use domain::{
|
||||
models::RemoteWatchlistEntry,
|
||||
models::{RemoteWatchlistEntry, WatchlistWithMovie},
|
||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
||||
value_objects::{InstanceIdentity, UserId},
|
||||
};
|
||||
use k_ap::ApObjectHandler;
|
||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||
use url::Url;
|
||||
|
||||
use crate::objects::WatchlistObject;
|
||||
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
|
||||
use crate::urls::{actor_url, watchlist_entry_url};
|
||||
|
||||
pub struct WatchlistObjectHandler {
|
||||
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
||||
pub base_url: String,
|
||||
pub instance: InstanceIdentity,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApContentReader for WatchlistObjectHandler {
|
||||
async fn get_local_objects_page(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
_before: Option<DateTime<chrono::Utc>>,
|
||||
_limit: usize,
|
||||
) -> anyhow::Result<Vec<LocalObject>> {
|
||||
let uid = UserId::from_uuid(user_id);
|
||||
let entries = self
|
||||
.content_query
|
||||
.get_local_watchlist_for_user(&uid)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
||||
|
||||
let actor = actor_url(&self.instance, user_id);
|
||||
let follower_cc = format!("{}/followers", actor);
|
||||
let mut results = Vec::new();
|
||||
for WatchlistWithMovie { entry, movie } in entries {
|
||||
let ap_id = watchlist_entry_url(&self.instance, user_id, entry.movie_id.value());
|
||||
let published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc);
|
||||
let poster_url = movie
|
||||
.poster_path()
|
||||
.map(|p| self.instance.image_url_for(p.value()));
|
||||
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||
ap_id: ap_id.clone(),
|
||||
actor_url: actor.clone(),
|
||||
movie_title: movie.title().value().to_string(),
|
||||
release_year: movie.release_year().value(),
|
||||
external_metadata_id: movie
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string()),
|
||||
poster_url,
|
||||
added_at: published,
|
||||
base_url: self.instance.base_url().to_string(),
|
||||
});
|
||||
results.push(LocalObject {
|
||||
ap_id,
|
||||
object: serde_json::to_value(obj)?,
|
||||
published_at: published,
|
||||
to: vec![AS_PUBLIC.to_string()],
|
||||
cc: vec![follower_cc.clone()],
|
||||
bto: vec![],
|
||||
bcc: vec![],
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||
Ok(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -49,10 +106,32 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
||||
|
||||
async fn on_update(
|
||||
&self,
|
||||
_ap_id: &Url,
|
||||
_actor_url: &Url,
|
||||
_object: serde_json::Value,
|
||||
ap_id: &Url,
|
||||
actor_url: &Url,
|
||||
object: serde_json::Value,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut obj: WatchlistObject = match serde_json::from_value(object) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
tracing::warn!(ap_id = %ap_id, "ignoring malformed watchlist Update: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if obj.attributed_to != *actor_url {
|
||||
anyhow::bail!("watchlist Update actor does not match object attributed_to");
|
||||
}
|
||||
obj.movie_title = ammonia::clean(&obj.movie_title);
|
||||
let entry = RemoteWatchlistEntry {
|
||||
ap_id: ap_id.as_str().to_string(),
|
||||
actor_url: actor_url.as_str().to_string(),
|
||||
movie_title: obj.movie_title,
|
||||
release_year: obj.release_year,
|
||||
external_metadata_id: obj.external_metadata_id,
|
||||
poster_url: obj.poster_url,
|
||||
added_at: obj.published,
|
||||
};
|
||||
self.remote_watchlist_repo.save(entry).await?;
|
||||
tracing::info!(ap_id = %ap_id, "updated remote watchlist entry");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -71,36 +150,19 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_received(
|
||||
&self,
|
||||
_object_url: &Url,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_announce_of_remote(
|
||||
&self,
|
||||
_object_url: &Url,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
||||
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn on_mention(
|
||||
&self,
|
||||
_thought_ap_id: &Url,
|
||||
_mentioned_user_uuid: uuid::Uuid,
|
||||
_actor_url: &Url,
|
||||
) -> anyhow::Result<()> {
|
||||
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
11
crates/adapters/adapter-common/Cargo.toml
Normal file
11
crates/adapters/adapter-common/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "adapter-common"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
220
crates/adapters/adapter-common/src/lib.rs
Normal file
220
crates/adapters/adapter-common/src/lib.rs
Normal file
@@ -0,0 +1,220 @@
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
|
||||
WatchlistEntry, WatchlistWithMovie,
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, Username, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
|
||||
/// Map a [`sqlx::Error`] to a [`DomainError::InfrastructureError`], logging the
|
||||
/// underlying database error at `error` level.
|
||||
pub fn map_sqlx_error(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
/// Parse a string as a UUID, returning a [`DomainError`] on failure.
|
||||
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
|
||||
uuid::Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
/// Parse a `%Y-%m-%d %H:%M:%S` string into a [`chrono::NaiveDateTime`].
|
||||
pub fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
|
||||
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
||||
}
|
||||
|
||||
/// Format a [`chrono::NaiveDateTime`] as `%Y-%m-%d %H:%M:%S`.
|
||||
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
/// Convert a `YYYY-MM` string into a human-readable label like `Jan '24`.
|
||||
pub fn format_year_month(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let year = parts[0].get(2..).unwrap_or(parts[0]);
|
||||
let month = match parts[1] {
|
||||
"01" => "Jan",
|
||||
"02" => "Feb",
|
||||
"03" => "Mar",
|
||||
"04" => "Apr",
|
||||
"05" => "May",
|
||||
"06" => "Jun",
|
||||
"07" => "Jul",
|
||||
"08" => "Aug",
|
||||
"09" => "Sep",
|
||||
"10" => "Oct",
|
||||
"11" => "Nov",
|
||||
"12" => "Dec",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} '{}", month, year)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared row-to-domain conversion functions
|
||||
//
|
||||
// Each database adapter keeps its own `FromRow` structs (sqlite vs postgres
|
||||
// derive different impls) but the conversion from parsed row fields into
|
||||
// domain types is identical. These functions capture that shared logic so
|
||||
// each adapter's `into_domain()` becomes a one-liner delegation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert raw movie row fields into a [`Movie`] domain object.
|
||||
pub fn movie_row_to_domain(
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&id)?);
|
||||
let external_metadata_id = external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(title)?;
|
||||
let release_year = ReleaseYear::new(release_year as u16)?;
|
||||
let poster_path = poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw review row fields into a [`Review`] domain object.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn review_row_to_domain(
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
|
||||
let rating = Rating::new(rating as u8)?;
|
||||
let comment = comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&watched_at)?;
|
||||
let created_at = parse_datetime(&created_at)?;
|
||||
let source = match remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Assemble a [`MovieSummary`] from an already-converted [`Movie`] and extra
|
||||
/// metadata fields. The caller is responsible for converting genres into a
|
||||
/// `Vec<String>` (sqlite splits a comma-separated string, postgres receives a
|
||||
/// `Vec` directly).
|
||||
pub fn movie_summary_to_domain(
|
||||
movie: Movie,
|
||||
genres: Vec<String>,
|
||||
runtime_minutes: Option<i64>,
|
||||
original_language: Option<String>,
|
||||
overview: Option<String>,
|
||||
collection_name: Option<String>,
|
||||
) -> MovieSummary {
|
||||
MovieSummary {
|
||||
movie,
|
||||
genres,
|
||||
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
||||
original_language,
|
||||
overview,
|
||||
collection_name,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert raw aggregate stats into a [`MovieStats`] domain object.
|
||||
pub fn movie_stats_to_domain(
|
||||
total_count: i64,
|
||||
avg_rating: Option<f64>,
|
||||
federated_count: i64,
|
||||
rating_histogram: [i64; 5],
|
||||
) -> MovieStats {
|
||||
MovieStats {
|
||||
total_count: total_count as u64,
|
||||
avg_rating,
|
||||
federated_count: federated_count as u64,
|
||||
rating_histogram: [
|
||||
rating_histogram[0] as u64,
|
||||
rating_histogram[1] as u64,
|
||||
rating_histogram[2] as u64,
|
||||
rating_histogram[3] as u64,
|
||||
rating_histogram[4] as u64,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert raw user summary row fields into a [`UserSummary`] domain object.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn user_summary_to_domain(
|
||||
id: String,
|
||||
email: String,
|
||||
username: String,
|
||||
display_name: Option<String>,
|
||||
total_movies: i64,
|
||||
avg_rating: Option<f64>,
|
||||
avatar_path: Option<String>,
|
||||
) -> Result<UserSummary, DomainError> {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&id)?),
|
||||
Email::new(email)?,
|
||||
Username::new(username)?,
|
||||
display_name,
|
||||
total_movies,
|
||||
avg_rating,
|
||||
avatar_path,
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert raw watchlist entry fields into a [`WatchlistEntry`] domain object.
|
||||
pub fn watchlist_entry_to_domain(
|
||||
id: String,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
added_at: String,
|
||||
) -> Result<WatchlistEntry, DomainError> {
|
||||
Ok(WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
|
||||
added_at: parse_datetime(&added_at)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert raw watchlist+movie row fields into a [`WatchlistWithMovie`].
|
||||
///
|
||||
/// Takes the watchlist entry fields and a pre-converted [`Movie`].
|
||||
pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie {
|
||||
WatchlistWithMovie { entry, movie }
|
||||
}
|
||||
@@ -4,7 +4,8 @@ use domain::{
|
||||
events::DomainEvent,
|
||||
models::{ExternalPersonId, PersonId},
|
||||
value_objects::{
|
||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId,
|
||||
WrapUpId,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -61,10 +62,40 @@ pub enum EventPayload {
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
},
|
||||
FollowRequested {
|
||||
follower_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
FollowAccepted {
|
||||
local_user_id: String,
|
||||
remote_actor_url: String,
|
||||
outbox_url: String,
|
||||
owner_id: String,
|
||||
requester_kind: String,
|
||||
requester_id: String,
|
||||
},
|
||||
FollowRejected {
|
||||
owner_id: String,
|
||||
requester_kind: String,
|
||||
requester_id: String,
|
||||
},
|
||||
Unfollowed {
|
||||
follower_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
FollowerRemoved {
|
||||
owner_id: String,
|
||||
follower_kind: String,
|
||||
follower_id: String,
|
||||
},
|
||||
ActorBlocked {
|
||||
blocker_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
ActorUnblocked {
|
||||
blocker_id: String,
|
||||
target_kind: String,
|
||||
target_id: String,
|
||||
},
|
||||
BackfillFollower {
|
||||
owner_user_id: String,
|
||||
@@ -72,7 +103,7 @@ pub enum EventPayload {
|
||||
},
|
||||
FederationDeliveryRequested {
|
||||
inbox_url: String,
|
||||
activity_json: String,
|
||||
activity_json: serde_json::Value,
|
||||
signing_actor_id: String,
|
||||
},
|
||||
WatchEventIngested {
|
||||
@@ -114,6 +145,13 @@ pub enum EventPayload {
|
||||
person_id: String,
|
||||
external_person_id: String,
|
||||
},
|
||||
UserDeleted {
|
||||
user_id: String,
|
||||
},
|
||||
UserAccountMoved {
|
||||
user_id: String,
|
||||
new_actor_url: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl EventPayload {
|
||||
@@ -129,7 +167,13 @@ impl EventPayload {
|
||||
EventPayload::ImageStored { .. } => "ImageStored",
|
||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
||||
EventPayload::FollowRequested { .. } => "FollowRequested",
|
||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||
EventPayload::FollowRejected { .. } => "FollowRejected",
|
||||
EventPayload::Unfollowed { .. } => "Unfollowed",
|
||||
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
|
||||
EventPayload::ActorBlocked { .. } => "ActorBlocked",
|
||||
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
|
||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||
@@ -141,6 +185,8 @@ impl EventPayload {
|
||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
||||
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
||||
EventPayload::UserDeleted { .. } => "UserDeleted",
|
||||
EventPayload::UserAccountMoved { .. } => "UserAccountMoved",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +195,44 @@ fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
|
||||
}
|
||||
|
||||
fn identity_to_payload(id: &SocialIdentity) -> (String, String) {
|
||||
match id {
|
||||
SocialIdentity::Local(uid) => ("local".into(), uid.value().to_string()),
|
||||
SocialIdentity::Remote { actor_url } => ("remote".into(), actor_url.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) {
|
||||
match target {
|
||||
domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id),
|
||||
domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainError> {
|
||||
match kind {
|
||||
"local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid(
|
||||
&id, "user_id",
|
||||
)?))),
|
||||
"remote" => Ok(SocialIdentity::Remote { actor_url: id }),
|
||||
other => Err(DomainError::InfrastructureError(format!(
|
||||
"unknown identity kind: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_to_follow_target(
|
||||
kind: &str,
|
||||
id: String,
|
||||
) -> Result<domain::value_objects::FollowTarget, DomainError> {
|
||||
match kind {
|
||||
"handle" => Ok(domain::value_objects::FollowTarget::Handle(id)),
|
||||
other => Ok(domain::value_objects::FollowTarget::Identity(
|
||||
payload_to_identity(other, id)?,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||
chrono::DateTime::from_timestamp(ts, 0)
|
||||
.map(|dt| dt.naive_utc())
|
||||
@@ -234,15 +318,62 @@ impl From<&DomainEvent> for EventPayload {
|
||||
movie_id: movie_id.value().to_string(),
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
} => EventPayload::FollowAccepted {
|
||||
local_user_id: local_user_id.value().to_string(),
|
||||
remote_actor_url: remote_actor_url.clone(),
|
||||
outbox_url: outbox_url.clone(),
|
||||
},
|
||||
DomainEvent::FollowRequested { follower, target } => {
|
||||
let (kind, id) = follow_target_to_payload(target);
|
||||
EventPayload::FollowRequested {
|
||||
follower_id: follower.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowAccepted { owner, requester } => {
|
||||
let (kind, id) = identity_to_payload(requester);
|
||||
EventPayload::FollowAccepted {
|
||||
owner_id: owner.value().to_string(),
|
||||
requester_kind: kind,
|
||||
requester_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowRejected { owner, requester } => {
|
||||
let (kind, id) = identity_to_payload(requester);
|
||||
EventPayload::FollowRejected {
|
||||
owner_id: owner.value().to_string(),
|
||||
requester_kind: kind,
|
||||
requester_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::Unfollowed { follower, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::Unfollowed {
|
||||
follower_id: follower.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::FollowerRemoved { owner, follower } => {
|
||||
let (kind, id) = identity_to_payload(follower);
|
||||
EventPayload::FollowerRemoved {
|
||||
owner_id: owner.value().to_string(),
|
||||
follower_kind: kind,
|
||||
follower_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::ActorBlocked { blocker, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::ActorBlocked {
|
||||
blocker_id: blocker.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::ActorUnblocked { blocker, target } => {
|
||||
let (kind, id) = identity_to_payload(target);
|
||||
EventPayload::ActorUnblocked {
|
||||
blocker_id: blocker.value().to_string(),
|
||||
target_kind: kind,
|
||||
target_id: id,
|
||||
}
|
||||
}
|
||||
DomainEvent::BackfillFollower {
|
||||
owner_user_id,
|
||||
follower_inbox_url,
|
||||
@@ -324,6 +455,16 @@ impl From<&DomainEvent> for EventPayload {
|
||||
person_id: person_id.value().to_string(),
|
||||
external_person_id: external_person_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::UserDeleted { user_id } => EventPayload::UserDeleted {
|
||||
user_id: user_id.value().to_string(),
|
||||
},
|
||||
DomainEvent::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => EventPayload::UserAccountMoved {
|
||||
user_id: user_id.value().to_string(),
|
||||
new_actor_url: new_actor_url.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,14 +557,61 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
|
||||
})
|
||||
}
|
||||
EventPayload::FollowRequested {
|
||||
follower_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::FollowRequested {
|
||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
target: payload_to_follow_target(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::FollowAccepted {
|
||||
local_user_id,
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
owner_id,
|
||||
requester_kind,
|
||||
requester_id,
|
||||
} => Ok(DomainEvent::FollowAccepted {
|
||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
||||
remote_actor_url,
|
||||
outbox_url,
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
||||
}),
|
||||
EventPayload::FollowRejected {
|
||||
owner_id,
|
||||
requester_kind,
|
||||
requester_id,
|
||||
} => Ok(DomainEvent::FollowRejected {
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
requester: payload_to_identity(&requester_kind, requester_id)?,
|
||||
}),
|
||||
EventPayload::Unfollowed {
|
||||
follower_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::Unfollowed {
|
||||
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::FollowerRemoved {
|
||||
owner_id,
|
||||
follower_kind,
|
||||
follower_id,
|
||||
} => Ok(DomainEvent::FollowerRemoved {
|
||||
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||
follower: payload_to_identity(&follower_kind, follower_id)?,
|
||||
}),
|
||||
EventPayload::ActorBlocked {
|
||||
blocker_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::ActorBlocked {
|
||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::ActorUnblocked {
|
||||
blocker_id,
|
||||
target_kind,
|
||||
target_id,
|
||||
} => Ok(DomainEvent::ActorUnblocked {
|
||||
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
|
||||
target: payload_to_identity(&target_kind, target_id)?,
|
||||
}),
|
||||
EventPayload::BackfillFollower {
|
||||
owner_user_id,
|
||||
@@ -517,6 +705,16 @@ impl TryFrom<EventPayload> for DomainEvent {
|
||||
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
||||
external_person_id: ExternalPersonId::new(external_person_id),
|
||||
}),
|
||||
EventPayload::UserDeleted { user_id } => Ok(DomainEvent::UserDeleted {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
}),
|
||||
EventPayload::UserAccountMoved {
|
||||
user_id,
|
||||
new_actor_url,
|
||||
} => Ok(DomainEvent::UserAccountMoved {
|
||||
user_id: UserId::from_uuid(parse_uuid(&user_id, "user_id")?),
|
||||
new_actor_url,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ fn make_entry_full(
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
DiaryEntry::new(movie, review)
|
||||
|
||||
@@ -3,7 +3,7 @@ use domain::{
|
||||
errors::DomainError,
|
||||
models::{MetadataSearchCriteria, Movie},
|
||||
ports::MetadataClient,
|
||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||
};
|
||||
|
||||
mod omdb;
|
||||
@@ -47,7 +47,9 @@ impl MetadataClient for MetadataClientImpl {
|
||||
criteria: &MetadataSearchCriteria,
|
||||
) -> Result<Movie, DomainError> {
|
||||
let pm = self.provider.fetch(criteria).await?;
|
||||
Ok(Movie::new(
|
||||
let movie_id = MovieId::from_external(&pm.imdb_id);
|
||||
Ok(Movie::from_persistence(
|
||||
movie_id,
|
||||
Some(pm.imdb_id),
|
||||
pm.title,
|
||||
pm.release_year,
|
||||
|
||||
@@ -12,7 +12,13 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::ImageStored { .. } => "image.stored",
|
||||
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
||||
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
||||
DomainEvent::FollowRequested { .. } => "follow.requested",
|
||||
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
||||
DomainEvent::FollowRejected { .. } => "follow.rejected",
|
||||
DomainEvent::Unfollowed { .. } => "follow.unfollowed",
|
||||
DomainEvent::FollowerRemoved { .. } => "follower.removed",
|
||||
DomainEvent::ActorBlocked { .. } => "actor.blocked",
|
||||
DomainEvent::ActorUnblocked { .. } => "actor.unblocked",
|
||||
DomainEvent::BackfillFollower { .. } => "backfill.follower",
|
||||
DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
||||
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
|
||||
@@ -24,6 +30,8 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
||||
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
||||
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
||||
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
|
||||
DomainEvent::UserDeleted { .. } => "user.deleted",
|
||||
DomainEvent::UserAccountMoved { .. } => "user.account.moved",
|
||||
};
|
||||
format!("{prefix}.{suffix}")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
ports::{ImageFetcher, PosterFetcherClient},
|
||||
value_objects::PosterUrl,
|
||||
};
|
||||
|
||||
pub struct ReqwestPosterFetcher {
|
||||
client: reqwest::Client,
|
||||
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ImageFetcher for ReqwestPosterFetcher {
|
||||
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
|
||||
let bytes = self
|
||||
.client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.error_for_status()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
|
||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||
PosterFetcherConfig::from_env(),
|
||||
)?))
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
ports::{
|
||||
EventHandler, EventPublisher, MetadataClient, MovieRepository, ObjectStorage,
|
||||
EventHandler, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage,
|
||||
PosterFetcherClient,
|
||||
},
|
||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||
};
|
||||
|
||||
pub struct PosterSyncHandler {
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
movie_command: Arc<dyn MovieCommand>,
|
||||
movie_query: Arc<dyn MovieQuery>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
@@ -22,7 +23,8 @@ pub struct PosterSyncHandler {
|
||||
|
||||
impl PosterSyncHandler {
|
||||
pub fn new(
|
||||
movie_repository: Arc<dyn MovieRepository>,
|
||||
movie_command: Arc<dyn MovieCommand>,
|
||||
movie_query: Arc<dyn MovieQuery>,
|
||||
metadata_client: Arc<dyn MetadataClient>,
|
||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||
object_storage: Arc<dyn ObjectStorage>,
|
||||
@@ -30,7 +32,8 @@ impl PosterSyncHandler {
|
||||
max_retries: u32,
|
||||
) -> Self {
|
||||
Self {
|
||||
movie_repository,
|
||||
movie_command,
|
||||
movie_query,
|
||||
metadata_client,
|
||||
poster_fetcher,
|
||||
object_storage,
|
||||
@@ -44,7 +47,7 @@ impl PosterSyncHandler {
|
||||
movie_id: MovieId,
|
||||
external_metadata_id: ExternalMetadataId,
|
||||
) -> Result<(), DomainError> {
|
||||
let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? {
|
||||
let mut movie = match self.movie_query.get_movie_by_id(&movie_id).await? {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value());
|
||||
@@ -82,7 +85,7 @@ impl PosterSyncHandler {
|
||||
let poster_path = PosterPath::new(stored_path)?;
|
||||
|
||||
movie.update_poster(poster_path);
|
||||
self.movie_repository.upsert_movie(&movie).await?;
|
||||
self.movie_command.upsert_movie(&movie).await?;
|
||||
|
||||
if let Err(e) = self
|
||||
.event_publisher
|
||||
@@ -115,7 +118,7 @@ impl EventHandler for PosterSyncHandler {
|
||||
} => {
|
||||
// Only sync poster if the movie doesn't have one yet
|
||||
let already_has_poster = self
|
||||
.movie_repository
|
||||
.movie_query
|
||||
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
|
||||
.await?
|
||||
.map(|m| m.poster_path().is_some())
|
||||
|
||||
@@ -12,7 +12,9 @@ sqlx = { version = "0.8.6", features = [
|
||||
"chrono",
|
||||
] }
|
||||
activitypub = { workspace = true }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
adapter-common = { workspace = true }
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
postgres-social = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
30
crates/adapters/postgres-federation/src/activity.rs
Normal file
30
crates/adapters/postgres-federation/src/activity.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::ActivityRepository;
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityRepository for PostgresFederationRepository {
|
||||
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1")
|
||||
.bind(activity_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
102
crates/adapters/postgres-federation/src/actor.rs
Normal file
102
crates/adapters/postgres-federation/src/actor.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl KeypairRepository for PostgresFederationRepository {
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
|
||||
let uid = user_id.to_string();
|
||||
let row =
|
||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| Keypair {
|
||||
public_key: r.get("public_key"),
|
||||
private_key: r.get("private_key"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz)
|
||||
ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
|
||||
).bind(&uid).bind(&keypair.public_key).bind(&keypair.private_key).bind(&created_at).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorCache for PostgresFederationRepository {
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||
display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url,
|
||||
outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url),
|
||||
bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url,
|
||||
following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at",
|
||||
)
|
||||
.bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||
.bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url)
|
||||
.bind(&aka_json).bind(&fetched_at)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1");
|
||||
let row = sqlx::query(&q)
|
||||
.bind(actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnounceRepository for PostgresFederationRepository {
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()> {
|
||||
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING")
|
||||
.bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2")
|
||||
.bind(activity_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
}
|
||||
}
|
||||
91
crates/adapters/postgres-federation/src/blocklist.rs
Normal file
91
crates/adapters/postgres-federation/src/blocklist.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl DomainBlocklist for PostgresFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
|
||||
.bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.get("domain"),
|
||||
reason: r.get("reason"),
|
||||
blocked_at: r.get("blocked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActorBlocklist for PostgresFederationRepository {
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
|
||||
.bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC")
|
||||
.bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid).bind(actor_url).fetch_one(&self.pool).await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
197
crates/adapters/postgres-federation/src/follow/followers.rs
Normal file
197
crates/adapters/postgres-federation/src/follow/followers.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{
|
||||
PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status,
|
||||
};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowerWriter for PostgresFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES ($1, $2, $3, $4::timestamptz, $5)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
|
||||
status = EXCLUDED.status, follow_activity_id = EXCLUDED.follow_activity_id",
|
||||
).bind(&uid).bind(remote_actor_url).bind(status_str).bind(&created_at).bind(follow_activity_id).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
|
||||
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowerReader for PostgresFederationRepository {
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_accepted_follower_inboxes(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
|
||||
FROM ap_followers f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||
AND f.remote_actor_url NOT IN (SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1)",
|
||||
).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_accepted_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
106
crates/adapters/postgres-federation/src/follow/following.rs
Normal file
106
crates/adapters/postgres-federation/src/follow/following.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use crate::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
|
||||
use adapter_common::datetime_to_str;
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
|
||||
|
||||
#[async_trait]
|
||||
impl FollowingWriter for PostgresFederationRepository {
|
||||
async fn add_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
|
||||
sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING")
|
||||
.bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = match status {
|
||||
FollowingStatus::Pending => "pending",
|
||||
FollowingStatus::Accepted => "accepted",
|
||||
};
|
||||
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
|
||||
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowingReader for PostgresFederationRepository {
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_following_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||
}
|
||||
}
|
||||
27
crates/adapters/postgres-federation/src/follow/migration.rs
Normal file
27
crates/adapters/postgres-federation/src/follow/migration.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use k_ap::FollowMigration;
|
||||
|
||||
use crate::PostgresFederationRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowMigration for PostgresFederationRepository {
|
||||
async fn migrate_follower_actor(
|
||||
&self,
|
||||
old_actor_url: &str,
|
||||
new_actor_url: &str,
|
||||
) -> Result<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
|
||||
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
|
||||
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
3
crates/adapters/postgres-federation/src/follow/mod.rs
Normal file
3
crates/adapters/postgres-federation/src/follow/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod followers;
|
||||
mod following;
|
||||
mod migration;
|
||||
@@ -1,31 +1,13 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
mod activity;
|
||||
mod actor;
|
||||
mod blocklist;
|
||||
mod follow;
|
||||
mod review;
|
||||
|
||||
use k_ap::{FollowerStatus, RemoteActor};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use activitypub::RemoteReviewRepository;
|
||||
use domain::models::{RemoteWatchlistEntry, Review, ReviewSource};
|
||||
use domain::ports::RemoteWatchlistRepository;
|
||||
use k_ap::{
|
||||
ActivityRepository, ActorRepository, BlockedDomain, BlocklistRepository, FollowRepository,
|
||||
Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
||||
};
|
||||
|
||||
fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
}
|
||||
|
||||
pub struct PostgresFederationRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresFederationRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_str(status: &FollowerStatus) -> &'static str {
|
||||
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
|
||||
match status {
|
||||
FollowerStatus::Pending => "pending",
|
||||
FollowerStatus::Accepted => "accepted",
|
||||
@@ -33,7 +15,7 @@ fn status_to_str(status: &FollowerStatus) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn str_to_status(s: &str) -> FollowerStatus {
|
||||
pub(crate) fn str_to_status(s: &str) -> FollowerStatus {
|
||||
match s {
|
||||
"accepted" => FollowerStatus::Accepted,
|
||||
"rejected" => FollowerStatus::Rejected,
|
||||
@@ -41,7 +23,7 @@ fn str_to_status(s: &str) -> FollowerStatus {
|
||||
}
|
||||
}
|
||||
|
||||
fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor {
|
||||
pub(crate) fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor {
|
||||
RemoteActor {
|
||||
url: row.get(url_col),
|
||||
handle: row.try_get("handle").unwrap_or_default(),
|
||||
@@ -69,871 +51,35 @@ fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor {
|
||||
}
|
||||
}
|
||||
|
||||
const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at";
|
||||
pub(crate) const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at";
|
||||
|
||||
#[async_trait]
|
||||
impl FollowRepository for PostgresFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES ($1, $2, $3, $4::timestamptz, $5)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
follow_activity_id = EXCLUDED.follow_activity_id",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&created_at)
|
||||
.bind(follow_activity_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
pub struct PostgresFederationRepository {
|
||||
pub(crate) pool: PgPool,
|
||||
}
|
||||
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
|
||||
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS}
|
||||
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: pg_remote_actor(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||
).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
|
||||
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'pending'"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_accepted_follower_inboxes(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
|
||||
FROM ap_followers f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||
AND f.remote_actor_url NOT IN (
|
||||
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1
|
||||
)",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_accepted_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT f.remote_actor_url, {PG_ACTOR_COLS}
|
||||
FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| pg_remote_actor(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn add_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
||||
VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING",
|
||||
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<String> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT a.url, {PG_ACTOR_COLS}
|
||||
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'"
|
||||
);
|
||||
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_following_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let q = format!(
|
||||
"SELECT a.url, {PG_ACTOR_COLS}
|
||||
FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
|
||||
);
|
||||
let rows = sqlx::query(&q)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = match status {
|
||||
FollowingStatus::Pending => "pending",
|
||||
FollowingStatus::Accepted => "accepted",
|
||||
};
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||
).bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_following_outbox_url(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
|
||||
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row.flatten())
|
||||
}
|
||||
|
||||
async fn migrate_follower_actor(
|
||||
&self,
|
||||
old_actor_url: &str,
|
||||
new_actor_url: &str,
|
||||
) -> Result<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1
|
||||
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
|
||||
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2
|
||||
AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)",
|
||||
).bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||
.collect()
|
||||
impl PostgresFederationRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActorRepository for PostgresFederationRepository {
|
||||
async fn get_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
let uid = user_id.to_string();
|
||||
let row =
|
||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
|
||||
}
|
||||
|
||||
async fn save_local_actor_keypair(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
public_key: String,
|
||||
private_key: String,
|
||||
) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
||||
VALUES ($1, $2, $3, $4::timestamptz)
|
||||
ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
|
||||
).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url,
|
||||
display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url,
|
||||
outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url),
|
||||
bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url,
|
||||
following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at",
|
||||
)
|
||||
.bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||
.bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url)
|
||||
.bind(&aka_json).bind(&fetched_at)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1");
|
||||
let row = sqlx::query(&q)
|
||||
.bind(actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
|
||||
}
|
||||
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()> {
|
||||
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING")
|
||||
.bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2")
|
||||
.bind(activity_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
pub fn wire(
|
||||
pool: PgPool,
|
||||
instance: domain::value_objects::InstanceIdentity,
|
||||
) -> activitypub::FederationRepos {
|
||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone()));
|
||||
let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new(
|
||||
pool, instance,
|
||||
));
|
||||
activitypub::FederationRepos {
|
||||
activity: std::sync::Arc::clone(&fed) as _,
|
||||
follow: std::sync::Arc::clone(&fed) as _,
|
||||
actor: std::sync::Arc::clone(&fed) as _,
|
||||
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||
review_store: fed as _,
|
||||
admin_query: std::sync::Arc::clone(&social) as _,
|
||||
remote_watchlist: std::sync::Arc::clone(&social) as _,
|
||||
follow_command: std::sync::Arc::clone(&social) as _,
|
||||
follow_query: social as _,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BlocklistRepository for PostgresFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
|
||||
.bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.get("domain"),
|
||||
reason: r.get("reason"),
|
||||
blocked_at: r.get("blocked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING")
|
||||
.bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC")
|
||||
.bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid).bind(actor_url).fetch_one(&self.pool).await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityRepository for PostgresFederationRepository {
|
||||
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1")
|
||||
.bind(activity_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(activity_id)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let actor_url = match review.source() {
|
||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||
ReviewSource::Local => {
|
||||
return Err(anyhow!("save_remote_review called with a local review"));
|
||||
}
|
||||
};
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES ($1, NULL, $2, $3, NULL, $4)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(movie_title)
|
||||
.bind(release_year.max(1888) as i64)
|
||||
.bind(poster_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let id = review.id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
.bind(&user_id)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
rating: u8,
|
||||
comment: Option<&str>,
|
||||
watched_at: chrono::NaiveDateTime,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz
|
||||
WHERE ap_id = $4 AND remote_actor_url = $5",
|
||||
)
|
||||
.bind(rating as i64)
|
||||
.bind(comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if let Some(url) = poster_url {
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = $1
|
||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
|
||||
)
|
||||
.bind(url)
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::SocialQueryPort for PostgresFederationRepository {
|
||||
async fn get_accepted_following_urls(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<String>, domain::errors::DomainError> {
|
||||
let user_id_str = user_id.to_string();
|
||||
sqlx::query_scalar::<_, String>(
|
||||
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&user_id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))
|
||||
}
|
||||
|
||||
async fn list_all_followed_remote_actors(
|
||||
&self,
|
||||
) -> Result<Vec<domain::models::RemoteActorInfo>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||
FROM ap_remote_actors ar
|
||||
JOIN ap_following f ON f.remote_actor_url = ar.url
|
||||
WHERE f.status = 'accepted'",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name)| domain::models::RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<usize, domain::errors::DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<usize, domain::errors::DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<domain::models::PendingFollowerInfo>, domain::errors::DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
|
||||
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
|
||||
FROM ap_followers f
|
||||
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $1 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(url, handle, display_name, avatar_url)| domain::models::PendingFollowerInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for PostgresFederationRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id)
|
||||
.bind(&entry.actor_url)
|
||||
.bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i32)
|
||||
.bind(&entry.external_metadata_id)
|
||||
.bind(&entry.poster_url)
|
||||
.bind(entry.added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(
|
||||
&self,
|
||||
ap_id: &str,
|
||||
actor_url: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC",
|
||||
)
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at: row
|
||||
.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at")
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<(), domain::errors::DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, domain::errors::DomainError> {
|
||||
let actors: Vec<String> =
|
||||
sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| domain::errors::DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wire(pool: sqlx::PgPool) -> activitypub::FederationRepos {
|
||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
||||
(
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
std::sync::Arc::clone(&fed) as _,
|
||||
fed as _,
|
||||
)
|
||||
}
|
||||
|
||||
110
crates/adapters/postgres-federation/src/review.rs
Normal file
110
crates/adapters/postgres-federation/src/review.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
|
||||
use super::PostgresFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteReviewRepository for PostgresFederationRepository {
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let actor_url = match review.source() {
|
||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||
ReviewSource::Local => {
|
||||
return Err(anyhow!("save_remote_review called with a local review"));
|
||||
}
|
||||
};
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES ($1, $2, $3, $4, NULL, $5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id),
|
||||
poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(external_metadata_id)
|
||||
.bind(movie_title)
|
||||
.bind(release_year.max(1888) as i64)
|
||||
.bind(poster_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let id = review.id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
.bind(&user_id)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
|
||||
WHERE ap_id = $5 AND remote_actor_url = $6",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if let Some(url) = u.poster_url {
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = $1
|
||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
|
||||
)
|
||||
.bind(url)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn S
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchCommand for PostgresSearchAdapter {
|
||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
||||
@@ -91,7 +87,7 @@ impl SearchCommand for PostgresSearchAdapter {
|
||||
.bind(&fts_input)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -113,7 +109,7 @@ impl SearchCommand for PostgresSearchAdapter {
|
||||
.bind(&fts_input)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -127,14 +123,14 @@ impl SearchCommand for PostgresSearchAdapter {
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
EntityType::Person => {
|
||||
sqlx::query("DELETE FROM people_search WHERE person_id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -183,7 +179,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(query.filters.year.map(|y| y as i32))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
} else {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
@@ -196,7 +192,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(query.filters.year.map(|y| y as i32))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
@@ -221,7 +217,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||
@@ -238,7 +234,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
};
|
||||
|
||||
let items = rows
|
||||
@@ -290,7 +286,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(text)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
@@ -316,7 +312,7 @@ impl PostgresSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
|
||||
18
crates/adapters/postgres-social/Cargo.toml
Normal file
18
crates/adapters/postgres-social/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "postgres-social"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sqlx = { version = "0.8.6", features = [
|
||||
"runtime-tokio-rustls",
|
||||
"postgres",
|
||||
"uuid",
|
||||
"macros",
|
||||
"chrono",
|
||||
] }
|
||||
adapter-common = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
@@ -1,14 +1,18 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DiaryEntry, Movie, Review, WatchlistEntry, WatchlistWithMovie},
|
||||
models::{
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{MovieId, ReviewId, UserId, WatchlistEntryId},
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{DiaryRow, MovieRow, ReviewRow, parse_datetime, parse_uuid};
|
||||
|
||||
pub struct PostgresApContentQuery {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -17,38 +21,134 @@ impl PostgresApContentQuery {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
// ── Local row types ──────────────────────────────────────────────────────────
|
||||
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MovieRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReviewRow {
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
let source = match self.remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DiaryRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for PostgresApContentQuery {
|
||||
async fn get_local_reviews_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -67,7 +167,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
@@ -125,7 +225,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -134,50 +235,10 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
.bind(&mid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query_as::<_, ReviewRow>(
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn count_local_posts(&self) -> Result<u64, DomainError> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -194,7 +255,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
|
||||
@@ -206,14 +268,15 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||
@@ -224,35 +287,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_goal_with_progress(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
year: u16,
|
||||
) -> Result<Option<(domain::models::Goal, u32)>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let y = year as i64;
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT id, user_id, year, target_count, goal_type, \
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
|
||||
FROM goals WHERE user_id = $1 AND year = $2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
let goal = crate::goals::row_to_goal(&r)?;
|
||||
let count = crate::goals::count_reviews_in_year(&self.pool, user_id, year).await?;
|
||||
|
||||
Ok(Some((goal, count)))
|
||||
}
|
||||
}
|
||||
58
crates/adapters/postgres-social/src/federated_profile.rs
Normal file
58
crates/adapters/postgres-social/src/federated_profile.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::PostgresSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for PostgresSocialRepository {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
let uid = synthetic_user_id.to_string();
|
||||
|
||||
let actor_url: Option<String> = sqlx::query_scalar(
|
||||
"SELECT remote_actor_url FROM reviews
|
||||
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let actor_url = match actor_url {
|
||||
Some(url) => url,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||
FROM ap_remote_actors WHERE url = $1",
|
||||
)
|
||||
.bind(&actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(FederatedProfile {
|
||||
actor_url,
|
||||
handle: r.get("handle"),
|
||||
display_name: r.try_get("display_name").ok().flatten(),
|
||||
bio: r.try_get("bio").ok().flatten(),
|
||||
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||
})),
|
||||
None => Ok(Some(FederatedProfile {
|
||||
handle: actor_url.clone(),
|
||||
actor_url,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
354
crates/adapters/postgres-social/src/follow_repository.rs
Normal file
354
crates/adapters/postgres-social/src/follow_repository.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::PostgresSocialRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||
match status {
|
||||
FollowStatus::Pending => "pending",
|
||||
FollowStatus::Accepted => "accepted",
|
||||
FollowStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
|
||||
match status {
|
||||
"pending" => Some(FollowStatus::Pending),
|
||||
"accepted" => Some(FollowStatus::Accepted),
|
||||
"rejected" => Some(FollowStatus::Rejected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowCommand for PostgresSocialRepository {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
||||
VALUES ($1, $2, '', $3::timestamptz, $4)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.bind(&now)
|
||||
.bind(status_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES ($1, $2, $3, $4::timestamptz, '')
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2")
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor {
|
||||
let actor_url: String = row.get("remote_actor_url");
|
||||
let identity = instance.identify(&actor_url);
|
||||
|
||||
let (handle, display_name, avatar_url) = match &identity {
|
||||
SocialIdentity::Local(_) => {
|
||||
let username: Option<String> = row.try_get("local_username").ok().flatten();
|
||||
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
||||
let avatar: Option<String> = row
|
||||
.try_get::<Option<String>, _>("local_avatar_path")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| instance.image_url_for(&p));
|
||||
let handle = username
|
||||
.as_deref()
|
||||
.map(|u| instance.handle_for(u))
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
(handle, display, avatar)
|
||||
}
|
||||
SocialIdentity::Remote { .. } => {
|
||||
let handle: String = row
|
||||
.try_get::<Option<String>, _>("remote_handle")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||
(handle, display, avatar)
|
||||
}
|
||||
};
|
||||
|
||||
SocialActor {
|
||||
identity,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowQuery for PostgresSocialRepository {
|
||||
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = $2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let uid = viewer_id.to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT (SELECT status FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2) AS following,
|
||||
(SELECT status FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2) AS followed_by",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
|
||||
Ok(FollowRelation {
|
||||
following: row
|
||||
.try_get::<Option<String>, _>("following")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
followed_by: row
|
||||
.try_get::<Option<String>, _>("followed_by")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
})
|
||||
}
|
||||
}
|
||||
38
crates/adapters/postgres-social/src/lib.rs
Normal file
38
crates/adapters/postgres-social/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod federated_profile;
|
||||
mod follow_repository;
|
||||
mod social;
|
||||
mod watchlist;
|
||||
|
||||
pub mod ap_content;
|
||||
pub mod remote_goals;
|
||||
|
||||
pub use ap_content::PostgresApContentQuery;
|
||||
pub use remote_goals::PostgresRemoteGoalRepository;
|
||||
|
||||
use sqlx::PgPool;
|
||||
|
||||
/// Postgres-backed implementations of the *domain* social ports.
|
||||
///
|
||||
/// Deliberately separate from `postgres-federation`: this crate knows nothing
|
||||
/// about ActivityPub, which is what allows a build with the `federation`
|
||||
/// feature off to exclude the federation stack entirely. See ADR-0009.
|
||||
///
|
||||
/// Shares the `ap_followers` / `ap_following` tables with
|
||||
/// `postgres-federation`; neither crate owns migrations.
|
||||
pub struct PostgresSocialRepository {
|
||||
pub(crate) pool: PgPool,
|
||||
pub(crate) instance: domain::value_objects::InstanceIdentity,
|
||||
}
|
||||
|
||||
impl PostgresSocialRepository {
|
||||
pub fn new(pool: PgPool, instance: domain::value_objects::InstanceIdentity) -> Self {
|
||||
Self { pool, instance }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_federated_profile_query(
|
||||
pool: PgPool,
|
||||
instance: domain::value_objects::InstanceIdentity,
|
||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||
std::sync::Arc::new(PostgresSocialRepository::new(pool, instance))
|
||||
}
|
||||
@@ -11,11 +11,6 @@ impl PostgresRemoteGoalRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -38,7 +33,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
||||
.bind(&received)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -57,7 +52,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -68,7 +63,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -78,7 +73,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -92,7 +87,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
21
crates/adapters/postgres-social/src/social.rs
Normal file
21
crates/adapters/postgres-social/src/social.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::PostgresSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederationAdminQuery for PostgresSocialRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'",
|
||||
).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(url, handle, display_name)| RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
91
crates/adapters/postgres-social/src/watchlist.rs
Normal file
91
crates/adapters/postgres-social/src/watchlist.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::PostgresSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteWatchlistRepository for PostgresSocialRepository {
|
||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_watchlist_entries \
|
||||
(ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
ON CONFLICT(ap_id) DO UPDATE SET \
|
||||
movie_title=excluded.movie_title, release_year=excluded.release_year, \
|
||||
external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url",
|
||||
)
|
||||
.bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title)
|
||||
.bind(entry.release_year as i32).bind(&entry.external_metadata_id).bind(&entry.poster_url)
|
||||
.bind(entry.added_at)
|
||||
.execute(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_actor_url(
|
||||
&self,
|
||||
actor_url: &str,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \
|
||||
FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC",
|
||||
).bind(actor_url).fetch_all(&self.pool).await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(RemoteWatchlistEntry {
|
||||
ap_id: row.try_get("ap_id").unwrap_or_default(),
|
||||
actor_url: row.try_get("actor_url").unwrap_or_default(),
|
||||
movie_title: row.try_get("movie_title").unwrap_or_default(),
|
||||
release_year: row.try_get::<i32, _>("release_year").unwrap_or(0) as u16,
|
||||
external_metadata_id: row.try_get("external_metadata_id").ok().flatten(),
|
||||
poster_url: row.try_get("poster_url").ok().flatten(),
|
||||
added_at: row
|
||||
.try_get::<chrono::DateTime<chrono::Utc>, _>("added_at")
|
||||
.unwrap_or_else(|_| chrono::Utc::now()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_by_derived_uuid(
|
||||
&self,
|
||||
uuid: uuid::Uuid,
|
||||
) -> Result<Vec<RemoteWatchlistEntry>, DomainError> {
|
||||
let actors: Vec<String> =
|
||||
sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries")
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
|
||||
.into_iter()
|
||||
.filter_map(|row| row.try_get::<String, _>("actor_url").ok())
|
||||
.collect();
|
||||
let target = actors
|
||||
.into_iter()
|
||||
.find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid);
|
||||
match target {
|
||||
None => Ok(vec![]),
|
||||
Some(actor_url) => self.get_by_actor_url(&actor_url).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ sqlx = { version = "0.8.6", features = [
|
||||
"macros",
|
||||
"chrono",
|
||||
] }
|
||||
adapter-common = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
postgres-social = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
|
||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
|
||||
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::DiaryRepository,
|
||||
ports::DiaryQuery,
|
||||
value_objects::{MovieId, UserId},
|
||||
};
|
||||
use futures::stream::BoxStream;
|
||||
@@ -22,45 +22,41 @@ impl PostgresDiaryRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
||||
match movie_id {
|
||||
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err),
|
||||
.map_err(adapter_common::map_sqlx_error),
|
||||
Some(id) => {
|
||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
|
||||
.bind(id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_all_diary_rows(
|
||||
&self,
|
||||
sort: &SortDirection,
|
||||
sort: &ReviewSortBy,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let order = match sort {
|
||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
SortDirection::Ascending => "r.watched_at ASC",
|
||||
SortDirection::Descending => "r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
ORDER BY {}
|
||||
@@ -72,28 +68,29 @@ impl PostgresDiaryRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_movie_diary_rows(
|
||||
&self,
|
||||
movie_id: &str,
|
||||
sort: &SortDirection,
|
||||
sort: &ReviewSortBy,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let order = match sort {
|
||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
SortDirection::Ascending => "r.watched_at ASC",
|
||||
SortDirection::Descending => "r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = $1
|
||||
@@ -107,51 +104,63 @@ impl PostgresDiaryRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn count_user_diary_entries(
|
||||
&self,
|
||||
user_id: &str,
|
||||
search: Option<&str>,
|
||||
include_remote: bool,
|
||||
) -> Result<i64, DomainError> {
|
||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let sql = if has_search {
|
||||
"SELECT COUNT(*) FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
|
||||
.to_string()
|
||||
let remote_clause = if include_remote {
|
||||
""
|
||||
} else {
|
||||
" AND r.remote_actor_url IS NULL"
|
||||
};
|
||||
let search_clause = if has_search {
|
||||
" AND m.title ILIKE '%' || $2 || '%'"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let sql = format!(
|
||||
"SELECT COUNT(*) FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1"
|
||||
.to_string()
|
||||
};
|
||||
WHERE r.user_id = $1{remote_clause}{search_clause}"
|
||||
);
|
||||
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
||||
if has_search {
|
||||
q = q.bind(search.unwrap());
|
||||
}
|
||||
q.fetch_one(&self.pool).await.map_err(Self::map_err)
|
||||
q.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_user_diary_rows(
|
||||
&self,
|
||||
user_id: &str,
|
||||
sort: &SortDirection,
|
||||
sort: &ReviewSortBy,
|
||||
search: Option<&str>,
|
||||
include_remote: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||
let order_clause = match sort {
|
||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
SortDirection::Ascending => "r.watched_at ASC",
|
||||
SortDirection::Descending => "r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||
};
|
||||
let remote_clause = if include_remote {
|
||||
""
|
||||
} else {
|
||||
" AND r.remote_actor_url IS NULL"
|
||||
};
|
||||
|
||||
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
|
||||
let mut p: i32 = 1; // $1 is user_id
|
||||
let mut p: i32 = 1;
|
||||
let search_clause = if has_search {
|
||||
p += 1;
|
||||
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
|
||||
@@ -168,13 +177,13 @@ impl PostgresDiaryRepository {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1{}
|
||||
ORDER BY {}
|
||||
LIMIT {} OFFSET {}",
|
||||
search_clause, order_clause, limit_param, offset_param
|
||||
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||
ORDER BY {order_clause}
|
||||
LIMIT {limit_param} OFFSET {offset_param}",
|
||||
);
|
||||
|
||||
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
||||
@@ -185,12 +194,12 @@ impl PostgresDiaryRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DiaryRepository for PostgresDiaryRepository {
|
||||
impl DiaryQuery for PostgresDiaryRepository {
|
||||
async fn query_diary(
|
||||
&self,
|
||||
filter: &DiaryFilter,
|
||||
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
(None, Some(uid)) => {
|
||||
let uid_str = uid.value().to_string();
|
||||
let search = filter.search.as_deref();
|
||||
let inc = filter.include_remote;
|
||||
tokio::try_join!(
|
||||
self.count_user_diary_entries(&uid_str, search),
|
||||
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
|
||||
self.count_user_diary_entries(&uid_str, search, inc),
|
||||
self.fetch_user_diary_rows(
|
||||
&uid_str,
|
||||
&filter.sort_by,
|
||||
search,
|
||||
inc,
|
||||
limit,
|
||||
offset
|
||||
)
|
||||
)?
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
@@ -322,10 +339,13 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
COALESCE(u.email, r.remote_actor_url) AS user_email
|
||||
r.watch_medium,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||
WHERE {}
|
||||
ORDER BY {}
|
||||
LIMIT {} OFFSET {}",
|
||||
@@ -352,7 +372,10 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
}
|
||||
|
||||
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
|
||||
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
|
||||
let total = count_q
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
|
||||
let rows = rows_q
|
||||
@@ -360,7 +383,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
@@ -385,7 +408,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||
.into_domain()?;
|
||||
|
||||
@@ -393,13 +416,14 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||
)
|
||||
.bind(&id_str)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
@@ -414,7 +438,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -423,7 +448,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
@@ -440,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url
|
||||
r.remote_actor_url,
|
||||
r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
@@ -451,7 +477,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
while let Some(row) = futures::StreamExt::next(&mut rows).await {
|
||||
yield match row {
|
||||
Ok(r) => r.into_domain(),
|
||||
Err(e) => Err(Self::map_err(e)),
|
||||
Err(e) => Err(adapter_common::map_sqlx_error(e)),
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -474,7 +500,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
.map(MovieStatsRow::into_domain)
|
||||
}
|
||||
|
||||
@@ -491,7 +517,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(&id_str)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let rows = sqlx::query_as::<_, FeedRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
@@ -499,12 +525,13 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
r.remote_actor_url,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
||||
WHEN u.email IS NOT NULL THEN u.email
|
||||
ELSE r.user_id END AS user_email
|
||||
r.watch_medium,
|
||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
|
||||
WHERE r.movie_id = $1
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
@@ -514,7 +541,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
@@ -534,7 +561,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(count as u64)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{Goal, GoalType},
|
||||
ports::GoalRepository,
|
||||
ports::{GoalCommand, GoalQuery},
|
||||
value_objects::{GoalId, UserId},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{datetime_to_str, parse_datetime, parse_uuid};
|
||||
use adapter_common::{datetime_to_str, parse_datetime, parse_uuid};
|
||||
|
||||
pub struct PostgresGoalRepository {
|
||||
pool: PgPool,
|
||||
@@ -17,15 +17,10 @@ impl PostgresGoalRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalRepository for PostgresGoalRepository {
|
||||
impl GoalCommand for PostgresGoalRepository {
|
||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||
let id = goal.id().value().to_string();
|
||||
let user_id = goal.user_id().value().to_string();
|
||||
@@ -46,7 +41,7 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -60,7 +55,7 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("Goal not found".into()));
|
||||
@@ -77,14 +72,17 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound("Goal not found".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GoalQuery for PostgresGoalRepository {
|
||||
async fn find_by_user_and_year(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -102,7 +100,7 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
.bind(y)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| row_to_goal(&r)).transpose()
|
||||
}
|
||||
@@ -118,14 +116,10 @@ impl GoalRepository for PostgresGoalRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_goal).collect()
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn count_reviews_in_year(
|
||||
|
||||
@@ -96,11 +96,6 @@ impl PostgresImportProfileRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -118,7 +113,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
|
||||
@@ -139,7 +134,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
@@ -184,7 +179,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.bind(&id_str).bind(&uid_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| {
|
||||
Ok(ImportProfile {
|
||||
@@ -212,6 +207,6 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ use domain::{
|
||||
models::{
|
||||
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
|
||||
import::{DomainField, ImportRow, RowResult, Transform},
|
||||
import_session::PersistedImportSession,
|
||||
},
|
||||
ports::ImportSessionRepository,
|
||||
value_objects::{ImportSessionId, UserId},
|
||||
@@ -203,11 +202,6 @@ impl PostgresImportSessionRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("DB error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
fn serialize_session(
|
||||
s: &ImportSession,
|
||||
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||
@@ -267,7 +261,7 @@ impl PostgresImportSessionRepository {
|
||||
Ok(js.into_iter().map(annotated_from_json).collect())
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(ImportSession::from_persistence(PersistedImportSession {
|
||||
Ok(ImportSession {
|
||||
id: ImportSessionId::from_uuid(
|
||||
id.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
@@ -282,7 +276,7 @@ impl PostgresImportSessionRepository {
|
||||
row_results,
|
||||
created_at,
|
||||
expires_at,
|
||||
}))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +296,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get(
|
||||
@@ -332,7 +326,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.bind(&uid_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.map(|r| {
|
||||
Self::deserialize_session(
|
||||
@@ -360,7 +354,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||
@@ -370,14 +364,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
@@ -388,6 +382,6 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use domain::errors::DomainError;
|
||||
use sqlx::PgPool;
|
||||
|
||||
mod ap_content;
|
||||
mod diary;
|
||||
mod goals;
|
||||
mod image_ref;
|
||||
@@ -9,11 +8,11 @@ mod import_profile;
|
||||
mod import_session;
|
||||
mod models;
|
||||
mod movie;
|
||||
mod movie_dedup;
|
||||
mod persons;
|
||||
mod profile;
|
||||
mod profile_fields;
|
||||
mod refresh_sessions;
|
||||
mod remote_goals;
|
||||
mod review;
|
||||
mod stats;
|
||||
mod user_settings;
|
||||
@@ -22,13 +21,14 @@ mod watch_event;
|
||||
mod watchlist;
|
||||
mod wrapup;
|
||||
|
||||
pub use ap_content::PostgresApContentQuery;
|
||||
pub use diary::PostgresDiaryRepository;
|
||||
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
|
||||
pub use import_profile::PostgresImportProfileRepository;
|
||||
pub use import_session::PostgresImportSessionRepository;
|
||||
pub use movie::PostgresMovieRepository;
|
||||
pub use movie_dedup::PostgresMovieDeduplicator;
|
||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||
pub use postgres_social::PostgresApContentQuery;
|
||||
pub use profile::PostgresMovieProfileRepository;
|
||||
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||
pub use refresh_sessions::PostgresRefreshSessionAdapter;
|
||||
@@ -39,30 +39,6 @@ pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenReposito
|
||||
pub use watchlist::PostgresWatchlistRepository;
|
||||
pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery};
|
||||
|
||||
pub(crate) fn format_year_month(ym: &str) -> String {
|
||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||
if parts.len() != 2 {
|
||||
return ym.to_string();
|
||||
}
|
||||
let year = parts[0].get(2..).unwrap_or(parts[0]);
|
||||
let month = match parts[1] {
|
||||
"01" => "Jan",
|
||||
"02" => "Feb",
|
||||
"03" => "Mar",
|
||||
"04" => "Apr",
|
||||
"05" => "May",
|
||||
"06" => "Jun",
|
||||
"07" => "Jul",
|
||||
"08" => "Aug",
|
||||
"09" => "Sep",
|
||||
"10" => "Oct",
|
||||
"11" => "Nov",
|
||||
"12" => "Dec",
|
||||
_ => parts[1],
|
||||
};
|
||||
format!("{} '{}", month, year)
|
||||
}
|
||||
|
||||
pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> {
|
||||
sqlx::migrate!("./migrations")
|
||||
.set_ignore_missing(true)
|
||||
@@ -79,9 +55,10 @@ pub fn create_profile_fields_repo(
|
||||
|
||||
pub struct PostgresWireOutput {
|
||||
pub pool: PgPool,
|
||||
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||
pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
|
||||
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
|
||||
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||
pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
|
||||
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
||||
@@ -91,10 +68,12 @@ pub struct PostgresWireOutput {
|
||||
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
|
||||
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
|
||||
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
|
||||
pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
|
||||
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
|
||||
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
|
||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||
pub deduplicator: std::sync::Arc<dyn domain::ports::MovieDeduplicator>,
|
||||
}
|
||||
|
||||
pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
@@ -113,9 +92,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
user_settings::PostgresUserSettingsRepository::new(pool.clone()),
|
||||
);
|
||||
|
||||
let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone()));
|
||||
|
||||
Ok(PostgresWireOutput {
|
||||
pool: pool.clone(),
|
||||
movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _,
|
||||
movie_command: movie_repo.clone() as _,
|
||||
movie_query: movie_repo as _,
|
||||
review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _,
|
||||
diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _,
|
||||
stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _,
|
||||
@@ -129,10 +111,13 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
||||
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _,
|
||||
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
|
||||
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
|
||||
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
|
||||
goal_command: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
|
||||
goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
|
||||
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
|
||||
federation_settings: user_settings_repo as _,
|
||||
remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool))
|
||||
as _,
|
||||
remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new(
|
||||
pool.clone(),
|
||||
)) as _,
|
||||
deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
use chrono::NaiveDateTime;
|
||||
use adapter_common::{
|
||||
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
|
||||
user_summary_to_domain,
|
||||
};
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
|
||||
UserSummary,
|
||||
},
|
||||
value_objects::{
|
||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, Username,
|
||||
},
|
||||
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct MovieRow {
|
||||
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
|
||||
|
||||
impl MovieRow {
|
||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
self.poster_path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,23 +47,22 @@ pub(crate) struct MovieSummaryRow {
|
||||
|
||||
impl MovieSummaryRow {
|
||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(MovieSummary {
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
Ok(movie_summary_to_domain(
|
||||
movie,
|
||||
genres: self.genres.unwrap_or_default(),
|
||||
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
|
||||
original_language: self.original_language,
|
||||
overview: self.overview,
|
||||
collection_name: self.collection_name,
|
||||
})
|
||||
self.genres.unwrap_or_default(),
|
||||
self.runtime_minutes,
|
||||
self.original_language,
|
||||
self.overview,
|
||||
self.collection_name,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,31 +76,22 @@ pub(crate) struct ReviewRow {
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
let source = match self.remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
}))
|
||||
review_row_to_domain(
|
||||
self.id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,30 +111,30 @@ pub(crate) struct DiaryRow {
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.into_domain()?;
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
let review = review_row_to_domain(
|
||||
self.review_id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
)?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
@@ -178,28 +155,32 @@ pub(crate) struct FeedRow {
|
||||
pub watched_at: String,
|
||||
pub created_at: String,
|
||||
pub remote_actor_url: Option<String>,
|
||||
pub watch_medium: Option<String>,
|
||||
pub user_email: String,
|
||||
}
|
||||
|
||||
impl FeedRow {
|
||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||
let diary = DiaryRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
review_id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
}
|
||||
.into_domain()?;
|
||||
let movie = movie_row_to_domain(
|
||||
self.id,
|
||||
self.external_metadata_id,
|
||||
self.title,
|
||||
self.release_year,
|
||||
self.director,
|
||||
self.poster_path,
|
||||
)?;
|
||||
let review = review_row_to_domain(
|
||||
self.review_id,
|
||||
self.movie_id,
|
||||
self.user_id,
|
||||
self.rating,
|
||||
self.comment,
|
||||
self.watched_at,
|
||||
self.created_at,
|
||||
self.remote_actor_url,
|
||||
self.watch_medium,
|
||||
)?;
|
||||
let diary = DiaryEntry::new(movie, review);
|
||||
Ok(FeedEntry::new(diary, self.user_email))
|
||||
}
|
||||
}
|
||||
@@ -218,18 +199,18 @@ pub(crate) struct MovieStatsRow {
|
||||
|
||||
impl MovieStatsRow {
|
||||
pub fn into_domain(self) -> domain::models::MovieStats {
|
||||
domain::models::MovieStats {
|
||||
total_count: self.total_count as u64,
|
||||
avg_rating: self.avg_rating,
|
||||
federated_count: self.federated_count as u64,
|
||||
rating_histogram: [
|
||||
self.rating_1 as u64,
|
||||
self.rating_2 as u64,
|
||||
self.rating_3 as u64,
|
||||
self.rating_4 as u64,
|
||||
self.rating_5 as u64,
|
||||
movie_stats_to_domain(
|
||||
self.total_count,
|
||||
self.avg_rating,
|
||||
self.federated_count,
|
||||
[
|
||||
self.rating_1,
|
||||
self.rating_2,
|
||||
self.rating_3,
|
||||
self.rating_4,
|
||||
self.rating_5,
|
||||
],
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,16 +226,16 @@ pub(crate) struct UserSummaryRow {
|
||||
}
|
||||
|
||||
impl UserSummaryRow {
|
||||
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
|
||||
Ok(UserSummary::new(
|
||||
UserId::from_uuid(parse_uuid(&self.id)?),
|
||||
Email::new(self.email)?,
|
||||
Username::new(self.username)?,
|
||||
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
|
||||
user_summary_to_domain(
|
||||
self.id,
|
||||
self.email,
|
||||
self.username,
|
||||
self.display_name,
|
||||
self.total_movies,
|
||||
self.avg_rating,
|
||||
self.avatar_path,
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,16 +258,29 @@ pub(crate) struct MonthlyRatingRow {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
||||
Uuid::parse_str(s)
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct GenreCountRow {
|
||||
pub genre: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct RatingDistRow {
|
||||
pub rating: i64,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
|
||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct WatchMediumCountRow {
|
||||
pub watch_medium: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(crate) struct ActorAppearanceRow {
|
||||
pub tmdb_person_id: i64,
|
||||
pub name: String,
|
||||
pub profile_path: Option<String>,
|
||||
pub billing_order: i32,
|
||||
pub movie_id: String,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use domain::{
|
||||
Movie, MovieFilter, MovieSummary,
|
||||
collections::{PageParams, Paginated},
|
||||
},
|
||||
ports::MovieRepository,
|
||||
ports::{MovieCommand, MovieQuery},
|
||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
@@ -20,67 +20,10 @@ impl PostgresMovieRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieRepository for PostgresMovieRepository {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
let id = external_metadata_id.value();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
let title = title.value();
|
||||
let year = year.value() as i64;
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE title = $1 AND release_year = $2",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(year)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.into_iter()
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl MovieCommand for PostgresMovieRepository {
|
||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||
let id = movie.id().value().to_string();
|
||||
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
|
||||
@@ -107,7 +50,7 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(&poster_path)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -118,9 +61,64 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieQuery for PostgresMovieRepository {
|
||||
async fn get_movie_by_external_id(
|
||||
&self,
|
||||
external_metadata_id: &ExternalMetadataId,
|
||||
) -> Result<Option<Movie>, DomainError> {
|
||||
let id = external_metadata_id.value();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
|
||||
let id = movie_id.value().to_string();
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.map(MovieRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn get_movies_by_title_and_year(
|
||||
&self,
|
||||
title: &MovieTitle,
|
||||
year: &ReleaseYear,
|
||||
) -> Result<Vec<Movie>, DomainError> {
|
||||
let title = title.value();
|
||||
let year = year.value() as i64;
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE title = $1 AND release_year = $2",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(year)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(MovieRow::into_domain)
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn existing_external_ids(
|
||||
&self,
|
||||
@@ -136,7 +134,7 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(&vals)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
|
||||
@@ -159,7 +157,7 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(&years)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
@@ -208,7 +206,7 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let total: i64 = sqlx::query(
|
||||
"SELECT COUNT(DISTINCT m.id) \
|
||||
@@ -223,7 +221,7 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
.bind(genre)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.try_get(0)
|
||||
.unwrap_or(0);
|
||||
|
||||
@@ -239,4 +237,17 @@ impl MovieRepository for PostgresMovieRepository {
|
||||
offset: page.offset,
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, DomainError> {
|
||||
sqlx::query_as::<_, MovieRow>(
|
||||
"SELECT id, external_metadata_id, title, release_year, director, poster_path
|
||||
FROM movies WHERE external_metadata_id IS NOT NULL",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| r.into_domain())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
153
crates/adapters/postgres/src/movie_dedup.rs
Normal file
153
crates/adapters/postgres/src/movie_dedup.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct PostgresMovieDeduplicator {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresMovieDeduplicator {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl MovieDeduplicator for PostgresMovieDeduplicator {
|
||||
async fn merge_into_canonical(
|
||||
&self,
|
||||
old_id: &MovieId,
|
||||
canonical: &Movie,
|
||||
) -> Result<u64, DomainError> {
|
||||
let old = old_id.value().to_string();
|
||||
let new = canonical.id().value().to_string();
|
||||
let ext_id = canonical
|
||||
.external_metadata_id()
|
||||
.map(|id| id.value().to_string());
|
||||
let title = canonical.title().value().to_string();
|
||||
let year = canonical.release_year().value() as i64;
|
||||
let director = canonical.director().map(str::to_string);
|
||||
let poster = canonical.poster_path().map(|p| p.value().to_string());
|
||||
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 1. Upsert canonical movie record
|
||||
sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id),
|
||||
poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
|
||||
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 2. Re-point simple FK tables
|
||||
let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
let watchlist =
|
||||
sqlx::query("UPDATE watchlist_entries SET movie_id = $1 WHERE movie_id = $2")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
// 3. Re-point movie_profiles (PK — move only if canonical has none)
|
||||
let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = $1 WHERE movie_id = $2")
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.rows_affected();
|
||||
|
||||
// 4. Re-point enrichment tables with composite PKs (INSERT … ON CONFLICT DO NOTHING + DELETE)
|
||||
// Canonical's existing rows win on conflict — old duplicates are discarded.
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_genres (movie_id, tmdb_id, name)
|
||||
SELECT $1, tmdb_id, name FROM movie_genres WHERE movie_id = $2
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_keywords (movie_id, tmdb_id, name)
|
||||
SELECT $1, tmdb_id, name FROM movie_keywords WHERE movie_id = $2
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(&new)
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
|
||||
SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2
|
||||
ON CONFLICT DO NOTHING",
|
||||
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
|
||||
SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2
|
||||
ON CONFLICT DO NOTHING",
|
||||
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
|
||||
sqlx::query("DELETE FROM movies WHERE id = $1")
|
||||
.bind(&old)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(reviews + watchlist + watch_events + profiles)
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,6 @@ pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn P
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PersonCommand for PostgresPersonAdapter {
|
||||
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
|
||||
@@ -56,7 +52,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
.bind(person.profile_path())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -89,7 +85,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
.bind(batch_size as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let has_more = rows.len() as u32 >= batch_size;
|
||||
let mut count = 0u64;
|
||||
@@ -109,7 +105,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
.bind(&row.profile_path)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok((count, has_more))
|
||||
@@ -137,7 +133,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -151,7 +147,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
@@ -166,7 +162,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(id.value())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(row.map(PersonRow::into_person))
|
||||
}
|
||||
@@ -182,7 +178,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(id.value().to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.flatten();
|
||||
|
||||
let Some(tmdb_id) = tmdb_id else {
|
||||
@@ -219,7 +215,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(tmdb_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CastCredit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
@@ -238,7 +234,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(tmdb_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CrewCredit {
|
||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
||||
@@ -261,7 +257,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||
}
|
||||
@@ -279,7 +275,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
|
||||
@@ -16,11 +16,6 @@ impl PostgresMovieProfileRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -28,7 +23,11 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
|
||||
let movie_id = p.movie_id.value().to_string();
|
||||
|
||||
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
|
||||
let mut tx = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
r#"INSERT INTO movie_profiles
|
||||
@@ -61,35 +60,35 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(p.enriched_at)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
for g in &p.genres {
|
||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
||||
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
for k in &p.keywords {
|
||||
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
||||
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
|
||||
.execute(&mut *tx).await.map_err(Self::map_err)?;
|
||||
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
for c in &p.cast {
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_cast \
|
||||
@@ -104,14 +103,14 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&c.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
|
||||
.bind(&movie_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
for cr in &p.crew {
|
||||
sqlx::query(
|
||||
"INSERT INTO movie_crew \
|
||||
@@ -126,10 +125,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&cr.profile_path)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
|
||||
tx.commit().await.map_err(Self::map_err)
|
||||
tx.commit().await.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||
@@ -144,7 +143,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&movie_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let row = match row {
|
||||
Some(r) => r,
|
||||
@@ -159,7 +158,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Genre {
|
||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -171,7 +170,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| Keyword {
|
||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||
@@ -186,14 +185,17 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CastMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
character: r.try_get("character").unwrap_or_default(),
|
||||
billing_order: r.try_get::<i32, _>("billing_order").unwrap_or(0) as u32,
|
||||
profile_path: r.try_get("profile_path").ok(),
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -204,38 +206,47 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(&movie_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(|r| CrewMember {
|
||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||
name: r.try_get("name").unwrap_or_default(),
|
||||
job: r.try_get("job").unwrap_or_default(),
|
||||
department: r.try_get("department").unwrap_or_default(),
|
||||
profile_path: r.try_get("profile_path").ok(),
|
||||
profile_path: r
|
||||
.try_get::<Option<String>, _>("profile_path")
|
||||
.ok()
|
||||
.flatten(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Some(MovieProfile {
|
||||
movie_id: id.clone(),
|
||||
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
|
||||
imdb_id: row.try_get("imdb_id").ok(),
|
||||
overview: row.try_get("overview").ok(),
|
||||
tagline: row.try_get("tagline").ok(),
|
||||
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
|
||||
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
|
||||
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
|
||||
runtime_minutes: row
|
||||
.try_get::<Option<i32>, _>("runtime_minutes")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
budget_usd: row.try_get("budget_usd").ok(),
|
||||
revenue_usd: row.try_get("revenue_usd").ok(),
|
||||
vote_average: row.try_get("vote_average").ok(),
|
||||
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
|
||||
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
|
||||
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
|
||||
vote_count: row
|
||||
.try_get::<Option<i32>, _>("vote_count")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|v| v as u32),
|
||||
original_language: row.try_get("original_language").ok(),
|
||||
collection_name: row.try_get("collection_name").ok(),
|
||||
original_language: row
|
||||
.try_get::<Option<String>, _>("original_language")
|
||||
.ok()
|
||||
.flatten(),
|
||||
collection_name: row
|
||||
.try_get::<Option<String>, _>("collection_name")
|
||||
.ok()
|
||||
.flatten(),
|
||||
genres,
|
||||
keywords,
|
||||
cast,
|
||||
@@ -257,7 +268,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
||||
.bind(threshold)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
|
||||
@@ -16,10 +16,6 @@ impl PostgresRefreshSessionAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||
@@ -34,7 +30,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
.bind(session.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -48,7 +44,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
.bind(token)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.map(RefreshSessionRow::into_domain).transpose()
|
||||
}
|
||||
@@ -58,7 +54,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
.bind(token)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -67,7 +63,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
.bind(user_id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -75,7 +71,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
events::DomainEvent,
|
||||
models::{Review, ReviewSource},
|
||||
ports::ReviewRepository,
|
||||
value_objects::{ReviewId, UserId},
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::models::{ReviewRow, datetime_to_str};
|
||||
use crate::models::ReviewRow;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
pub struct PostgresReviewRepository {
|
||||
pool: PgPool,
|
||||
@@ -18,16 +18,11 @@ impl PostgresReviewRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReviewRepository for PostgresReviewRepository {
|
||||
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
|
||||
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
@@ -41,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)",
|
||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
@@ -52,17 +47,12 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&remote_actor_url)
|
||||
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(DomainEvent::ReviewLogged {
|
||||
review_id: review.id().clone(),
|
||||
movie_id: review.movie_id().clone(),
|
||||
user_id: review.user_id().clone(),
|
||||
rating: review.rating().clone(),
|
||||
watched_at: *review.watched_at(),
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
||||
@@ -71,24 +61,47 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
FROM reviews WHERE id = $1",
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.map(ReviewRow::into_domain)
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
|
||||
let id = review.id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
|
||||
)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&watch_medium)
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||
let id = review_id.value().to_string();
|
||||
sqlx::query("DELETE FROM reviews WHERE id = $1")
|
||||
.bind(&id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,13 +111,14 @@ impl ReviewRepository for PostgresReviewRepository {
|
||||
"SELECT id, movie_id, user_id, rating, comment,
|
||||
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
|
||||
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
|
||||
remote_actor_url
|
||||
remote_actor_url,
|
||||
watch_medium
|
||||
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(ReviewRow::into_domain)
|
||||
.collect()
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
|
||||
models::{
|
||||
ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors,
|
||||
},
|
||||
ports::StatsRepository,
|
||||
value_objects::UserId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::format_year_month;
|
||||
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
|
||||
use crate::models::{
|
||||
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||
UserTotalsRow, WatchMediumCountRow,
|
||||
};
|
||||
use adapter_common::format_year_month;
|
||||
|
||||
pub struct PostgresStatsRepository {
|
||||
pool: PgPool,
|
||||
@@ -19,11 +24,6 @@ impl PostgresStatsRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
||||
sqlx::query_as::<_, UserTotalsRow>(
|
||||
r#"SELECT COUNT(DISTINCT movie_id) AS total,
|
||||
@@ -33,7 +33,7 @@ impl PostgresStatsRepository {
|
||||
.bind(user_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_user_favorite_director(
|
||||
@@ -52,7 +52,7 @@ impl PostgresStatsRepository {
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
|
||||
async fn fetch_user_most_active_month(
|
||||
@@ -70,7 +70,7 @@ impl PostgresStatsRepository {
|
||||
.bind(user_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)
|
||||
.map_err(adapter_common::map_sqlx_error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,10 +95,15 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
|
||||
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
|
||||
}
|
||||
|
||||
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
let (rating_rows, director_rows) = tokio::try_join!(
|
||||
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) =
|
||||
tokio::try_join!(
|
||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
||||
AVG(rating::float) AS avg_rating,
|
||||
@@ -120,9 +125,48 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, GenreCountRow>(
|
||||
"SELECT mg.name AS genre, COUNT(*) AS count
|
||||
FROM reviews r
|
||||
INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
GROUP BY mg.name
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 5"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, RatingDistRow>(
|
||||
"SELECT rating, COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = $1
|
||||
GROUP BY rating
|
||||
ORDER BY rating ASC"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, WatchMediumCountRow>(
|
||||
"SELECT watch_medium, COUNT(*) AS count
|
||||
FROM reviews
|
||||
WHERE user_id = $1 AND watch_medium IS NOT NULL
|
||||
GROUP BY watch_medium
|
||||
ORDER BY COUNT(*) DESC"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool),
|
||||
sqlx::query_as::<_, ActorAppearanceRow>(
|
||||
"SELECT mc.tmdb_person_id, mc.name, mc.profile_path,
|
||||
mc.billing_order, mc.movie_id
|
||||
FROM reviews r
|
||||
INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id
|
||||
WHERE r.user_id = $1
|
||||
GROUP BY mc.tmdb_person_id, mc.movie_id, mc.name, mc.profile_path, mc.billing_order"
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
)
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||
|
||||
@@ -144,10 +188,52 @@ impl StatsRepository for PostgresStatsRepository {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let top_genres = genre_rows
|
||||
.into_iter()
|
||||
.map(|g| domain::models::stats::GenreStat {
|
||||
genre: g.genre,
|
||||
count: g.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let rating_distribution = {
|
||||
let mut dist = [0i64; 5];
|
||||
for r in &rating_dist_rows {
|
||||
let idx = (r.rating as usize).saturating_sub(1).min(4);
|
||||
dist[idx] = r.count;
|
||||
}
|
||||
dist
|
||||
};
|
||||
|
||||
let watch_medium_distribution = medium_rows
|
||||
.into_iter()
|
||||
.map(|m| domain::models::stats::WatchMediumStat {
|
||||
medium: m.watch_medium,
|
||||
count: m.count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let top_actors = compute_top_actors(
|
||||
actor_rows
|
||||
.into_iter()
|
||||
.map(|r| ActorAppearance {
|
||||
tmdb_person_id: r.tmdb_person_id as u64,
|
||||
name: r.name,
|
||||
profile_path: r.profile_path,
|
||||
billing_order: r.billing_order as u32,
|
||||
movie_id: r.movie_id,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
|
||||
Ok(UserTrends {
|
||||
monthly_ratings,
|
||||
top_directors,
|
||||
max_director_count,
|
||||
top_genres,
|
||||
rating_distribution,
|
||||
watch_medium_distribution,
|
||||
top_actors,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,6 @@ impl PostgresUserSettingsRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -33,7 +28,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
@@ -65,7 +60,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
|
||||
.bind(settings.federate_watchlist())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -81,7 +76,7 @@ impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
|
||||
@@ -20,11 +20,6 @@ impl PostgresUserRepository {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> UserRole {
|
||||
match s {
|
||||
"admin" => UserRole::Admin,
|
||||
@@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(email_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
.transpose()
|
||||
@@ -90,7 +85,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(username_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
row.as_ref()
|
||||
.map(|r| Self::row_to_user(r, vec![]))
|
||||
.transpose()
|
||||
@@ -130,7 +125,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(role)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -140,7 +135,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let Some(r) = row else { return Ok(None) };
|
||||
|
||||
@@ -197,7 +192,7 @@ impl UserRepository for PostgresUserRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.into_iter()
|
||||
.map(UserSummaryRow::into_domain)
|
||||
.collect()
|
||||
|
||||
@@ -2,17 +2,12 @@ use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
||||
ports::{WatchEventRepository, WebhookTokenRepository},
|
||||
ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
|
||||
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{parse_datetime, parse_uuid};
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
// ── WatchEventRepository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,7 +22,7 @@ impl PostgresWatchEventRepository {
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
impl WatchEventCommand for PostgresWatchEventRepository {
|
||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||
let id = event.id().value().to_string();
|
||||
let user_id = event.user_id().value().to_string();
|
||||
@@ -52,7 +47,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(event.created_at())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -70,11 +65,46 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
ids: &[WatchEventId],
|
||||
status: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
|
||||
let status_str = status.to_string();
|
||||
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
|
||||
.bind(&status_str)
|
||||
.bind(&id_strs)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
|
||||
.bind(before)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl WatchEventQuery for PostgresWatchEventRepository {
|
||||
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
|
||||
@@ -91,7 +121,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
@@ -110,7 +140,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_watch_event).transpose()
|
||||
}
|
||||
@@ -131,29 +161,10 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(&id_strs)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.iter().map(row_to_watch_event).collect()
|
||||
}
|
||||
|
||||
async fn update_status_batch(
|
||||
&self,
|
||||
ids: &[WatchEventId],
|
||||
status: WatchEventStatus,
|
||||
) -> Result<u64, DomainError> {
|
||||
if ids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
|
||||
let status_str = status.to_string();
|
||||
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
|
||||
.bind(&status_str)
|
||||
.bind(&id_strs)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn find_duplicate(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
@@ -171,36 +182,41 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
||||
.bind(after)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn delete_non_pending_older_than(
|
||||
&self,
|
||||
before: chrono::NaiveDateTime,
|
||||
) -> Result<u64, DomainError> {
|
||||
let result =
|
||||
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
|
||||
.bind(before)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result<WatchEvent, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
||||
let movie_id_str: Option<String> = row.try_get("movie_id").map_err(map_err)?;
|
||||
let title: String = row.try_get("title").map_err(map_err)?;
|
||||
let year: Option<i32> = row.try_get("year").map_err(map_err)?;
|
||||
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?;
|
||||
let source_str: String = row.try_get("source").map_err(map_err)?;
|
||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
|
||||
let user_id_str: String = row
|
||||
.try_get("user_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let movie_id_str: Option<String> = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let title: String = row
|
||||
.try_get("title")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let year: Option<i32> = row
|
||||
.try_get("year")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let ext_id: Option<String> = row
|
||||
.try_get("external_metadata_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let source_str: String = row
|
||||
.try_get("source")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let watched_at_str: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let status_str: String = row
|
||||
.try_get("status")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let source: WatchEventSource = source_str
|
||||
.parse()
|
||||
@@ -262,7 +278,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
.bind(token.last_used_at())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -277,7 +293,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
.bind(hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_webhook_token).transpose()
|
||||
}
|
||||
@@ -294,7 +310,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_webhook_token).collect()
|
||||
}
|
||||
@@ -308,7 +324,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
.bind(&uid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
||||
@@ -323,20 +339,32 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
||||
let token_hash: String = row.try_get("token_hash").map_err(map_err)?;
|
||||
let provider_str: String = row.try_get("provider").map_err(map_err)?;
|
||||
let label: Option<String> = row.try_get("label").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?;
|
||||
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
|
||||
let user_id_str: String = row
|
||||
.try_get("user_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let token_hash: String = row
|
||||
.try_get("token_hash")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let provider_str: String = row
|
||||
.try_get("provider")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let label: Option<String> = row
|
||||
.try_get("label")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let last_used_str: Option<String> = row
|
||||
.try_get("last_used_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let provider: WatchEventSource = provider_str
|
||||
.parse()
|
||||
|
||||
@@ -10,7 +10,8 @@ use domain::{
|
||||
};
|
||||
use sqlx::{PgPool, Row};
|
||||
|
||||
use crate::models::{MovieRow, parse_datetime, parse_uuid};
|
||||
use crate::models::MovieRow;
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
pub struct PostgresWatchlistRepository {
|
||||
pool: PgPool,
|
||||
@@ -20,11 +21,6 @@ impl PostgresWatchlistRepository {
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -46,7 +42,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.bind(added_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,7 +57,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DomainError::NotFound(format!(
|
||||
@@ -85,7 +81,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.bind(&mid)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
@@ -114,14 +110,14 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let total: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let items = rows
|
||||
.into_iter()
|
||||
@@ -187,7 +183,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
||||
.bind(&mid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,7 @@ use domain::{
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::models::{parse_datetime, parse_uuid};
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
fn status_to_str(s: &WrapUpStatus) -> &'static str {
|
||||
match s {
|
||||
@@ -76,7 +71,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(record.completed_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -96,7 +91,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -115,7 +110,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(&id_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -132,7 +127,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(&id_str)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_record).transpose()
|
||||
}
|
||||
@@ -149,7 +144,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_record).collect()
|
||||
}
|
||||
@@ -163,7 +158,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter().map(row_to_record).collect()
|
||||
}
|
||||
@@ -190,7 +185,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(end)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
row.as_ref().map(row_to_record).transpose()
|
||||
}
|
||||
@@ -200,7 +195,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(id.value().to_string())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -213,21 +208,37 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
||||
.bind(before)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> {
|
||||
let id_str: String = row.try_get("id").map_err(map_err)?;
|
||||
let user_id_str: Option<String> = row.try_get("user_id").map_err(map_err)?;
|
||||
let start_date: NaiveDate = row.try_get("start_date").map_err(map_err)?;
|
||||
let end_date: NaiveDate = row.try_get("end_date").map_err(map_err)?;
|
||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
||||
let report_json: Option<String> = row.try_get("report_json").map_err(map_err)?;
|
||||
let error_message: Option<String> = row.try_get("error_message").map_err(map_err)?;
|
||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
||||
let completed_at_str: Option<String> = row.try_get("completed_at").map_err(map_err)?;
|
||||
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
|
||||
let user_id_str: Option<String> = row
|
||||
.try_get("user_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let start_date: NaiveDate = row
|
||||
.try_get("start_date")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let end_date: NaiveDate = row
|
||||
.try_get("end_date")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let status_str: String = row
|
||||
.try_get("status")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let report_json: Option<String> = row
|
||||
.try_get("report_json")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let error_message: Option<String> = row
|
||||
.try_get("error_message")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let created_at_str: String = row
|
||||
.try_get("created_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let completed_at_str: Option<String> = row
|
||||
.try_get("completed_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
|
||||
|
||||
@@ -278,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||
r.rating, \
|
||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
|
||||
r.user_id, \
|
||||
r.user_id, r.watch_medium, \
|
||||
p.runtime_minutes, p.budget_usd, p.original_language \
|
||||
FROM reviews r \
|
||||
INNER JOIN movies m ON m.id = r.movie_id \
|
||||
@@ -292,7 +303,10 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
q = q.bind(uid);
|
||||
}
|
||||
|
||||
let rows = q.fetch_all(&self.pool).await.map_err(map_err)?;
|
||||
let rows = q
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(vec![]);
|
||||
@@ -302,7 +316,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
let mut movie_ids: Vec<String> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for row in &rows {
|
||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
if seen.insert(mid.clone()) {
|
||||
movie_ids.push(mid);
|
||||
}
|
||||
@@ -318,18 +334,42 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
// 3) Build result
|
||||
let mut result = Vec::with_capacity(rows.len());
|
||||
for row in &rows {
|
||||
let movie_id_str: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let title: String = row.try_get("title").map_err(map_err)?;
|
||||
let release_year: i64 = row.try_get("release_year").map_err(map_err)?;
|
||||
let director: Option<String> = row.try_get("director").map_err(map_err)?;
|
||||
let poster_path: Option<String> = row.try_get("poster_path").map_err(map_err)?;
|
||||
let rating: i64 = row.try_get("rating").map_err(map_err)?;
|
||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
||||
let runtime_minutes: Option<i32> = row.try_get("runtime_minutes").map_err(map_err)?;
|
||||
let budget_usd: Option<i64> = row.try_get("budget_usd").map_err(map_err)?;
|
||||
let original_language: Option<String> =
|
||||
row.try_get("original_language").map_err(map_err)?;
|
||||
let movie_id_str: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let title: String = row
|
||||
.try_get("title")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let release_year: i64 = row
|
||||
.try_get("release_year")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let director: Option<String> = row
|
||||
.try_get("director")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let poster_path: Option<String> = row
|
||||
.try_get("poster_path")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let rating: i64 = row
|
||||
.try_get("rating")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let watched_at_str: String = row
|
||||
.try_get("watched_at")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let user_id_str: String = row
|
||||
.try_get("user_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let runtime_minutes: Option<i32> = row
|
||||
.try_get("runtime_minutes")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let budget_usd: Option<i64> = row
|
||||
.try_get("budget_usd")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let original_language: Option<String> = row
|
||||
.try_get("original_language")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let watch_medium: Option<String> = row
|
||||
.try_get("watch_medium")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||
@@ -354,6 +394,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
||||
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
||||
budget_usd,
|
||||
original_language,
|
||||
watch_medium,
|
||||
genres,
|
||||
keywords,
|
||||
cast_names,
|
||||
@@ -383,12 +424,16 @@ async fn fetch_genres_pg(
|
||||
.bind(movie_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let name: String = row.try_get("name").map_err(map_err)?;
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let name: String = row
|
||||
.try_get("name")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(name);
|
||||
}
|
||||
Ok(map)
|
||||
@@ -404,12 +449,16 @@ async fn fetch_keywords_pg(
|
||||
.bind(movie_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let name: String = row.try_get("name").map_err(map_err)?;
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let name: String = row
|
||||
.try_get("name")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(name);
|
||||
}
|
||||
Ok(map)
|
||||
@@ -428,15 +477,25 @@ async fn fetch_cast_pg(
|
||||
.bind(movie_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
|
||||
for row in rows {
|
||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
||||
let name: String = row.try_get("name").map_err(map_err)?;
|
||||
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?;
|
||||
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?;
|
||||
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?;
|
||||
let mid: String = row
|
||||
.try_get("movie_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let name: String = row
|
||||
.try_get("name")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let billing_order: i32 = row
|
||||
.try_get("billing_order")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let tmdb_person_id: i64 = row
|
||||
.try_get("tmdb_person_id")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
let profile_path: Option<String> = row
|
||||
.try_get("profile_path")
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
map.entry(mid).or_default().push(CastEntry {
|
||||
name,
|
||||
billing_order: billing_order as u32,
|
||||
|
||||
@@ -6,4 +6,3 @@ edition = "2024"
|
||||
[dependencies]
|
||||
rss-feed = { package = "rss", version = "2" }
|
||||
domain = { workspace = true }
|
||||
application = { workspace = true }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use application::ports::RssFeedRenderer;
|
||||
use domain::models::DiaryEntry;
|
||||
use domain::ports::RssFeedRenderer;
|
||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||
|
||||
pub struct RssAdapter {
|
||||
|
||||
@@ -6,7 +6,9 @@ edition = "2024"
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
activitypub = { workspace = true }
|
||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
||||
adapter-common = { workspace = true }
|
||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||
sqlite-social = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
28
crates/adapters/sqlite-federation/src/activity.rs
Normal file
28
crates/adapters/sqlite-federation/src/activity.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::ActivityRepository;
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl ActivityRepository for SqliteFederationRepository {
|
||||
async fn is_activity_processed(&self, activity_id: &str) -> Result<bool> {
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = ?1")
|
||||
.bind(activity_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> {
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query("INSERT OR IGNORE INTO ap_activities (id, processed_at) VALUES (?1, ?2)")
|
||||
.bind(activity_id)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
119
crates/adapters/sqlite-federation/src/actor.rs
Normal file
119
crates/adapters/sqlite-federation/src/actor.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::{SqliteFederationRepository, remote_actor_from_row};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl KeypairRepository for SqliteFederationRepository {
|
||||
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
|
||||
let uid = user_id.to_string();
|
||||
let row =
|
||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| Keypair {
|
||||
public_key: r.get("public_key"),
|
||||
private_key: r.get("private_key"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
|
||||
let uid = user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
public_key = excluded.public_key,
|
||||
private_key = excluded.private_key",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&keypair.public_key)
|
||||
.bind(&keypair.private_key)
|
||||
.bind(&created_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteActorCache for SqliteFederationRepository {
|
||||
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let fetched_at = datetime_to_str(&now);
|
||||
let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default();
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(url) DO UPDATE SET
|
||||
handle = excluded.handle,
|
||||
inbox_url = excluded.inbox_url,
|
||||
shared_inbox_url = excluded.shared_inbox_url,
|
||||
display_name = excluded.display_name,
|
||||
avatar_url = excluded.avatar_url,
|
||||
outbox_url = COALESCE(excluded.outbox_url, ap_remote_actors.outbox_url),
|
||||
bio = excluded.bio,
|
||||
banner_url = excluded.banner_url,
|
||||
followers_url = excluded.followers_url,
|
||||
following_url = excluded.following_url,
|
||||
also_known_as = excluded.also_known_as,
|
||||
fetched_at = excluded.fetched_at",
|
||||
)
|
||||
.bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url)
|
||||
.bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url)
|
||||
.bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url)
|
||||
.bind(&aka_json).bind(&fetched_at)
|
||||
.execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_remote_actor(&self, actor_url: &str) -> Result<Option<RemoteActor>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT url, handle, inbox_url, shared_inbox_url, display_name, avatar_url,
|
||||
outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
).bind(actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AnnounceRepository for SqliteFederationRepository {
|
||||
async fn add_announce(
|
||||
&self,
|
||||
activity_id: &str,
|
||||
object_url: &str,
|
||||
actor_url: &str,
|
||||
announced_at: chrono::DateTime<chrono::Utc>,
|
||||
) -> Result<()> {
|
||||
let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO ap_announces (id, object_url, actor_url, announced_at) VALUES (?1, ?2, ?3, ?4)",
|
||||
).bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM ap_announces WHERE id = ?1 AND actor_url = ?2")
|
||||
.bind(activity_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_announces(&self, object_url: &str) -> Result<usize> {
|
||||
let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = ?1")
|
||||
.bind(object_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>("cnt") as usize)
|
||||
}
|
||||
}
|
||||
102
crates/adapters/sqlite-federation/src/blocklist.rs
Normal file
102
crates/adapters/sqlite-federation/src/blocklist.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl DomainBlocklist for SqliteFederationRepository {
|
||||
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
|
||||
let now = Utc::now().naive_utc();
|
||||
let ts = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(domain) DO UPDATE SET reason = excluded.reason",
|
||||
)
|
||||
.bind(domain)
|
||||
.bind(reason)
|
||||
.bind(&ts)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_domain(&self, domain: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_domains WHERE domain = ?1")
|
||||
.bind(domain)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_domains(&self) -> Result<Vec<BlockedDomain>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| BlockedDomain {
|
||||
domain: r.get("domain"),
|
||||
reason: r.get("reason"),
|
||||
blocked_at: r.get("blocked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_domain_blocked(&self, domain: &str) -> Result<bool> {
|
||||
let count: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = ?1")
|
||||
.bind(domain)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ActorBlocklist for SqliteFederationRepository {
|
||||
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let ts = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES (?1, ?2, ?3)",
|
||||
).bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query(
|
||||
"DELETE FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?1 ORDER BY blocked_at DESC",
|
||||
).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| r.get::<String, _>("remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = ?1 AND remote_actor_url = ?2",
|
||||
).bind(&uid).bind(actor_url).fetch_one(&self.pool).await?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
}
|
||||
236
crates/adapters/sqlite-federation/src/follow/followers.rs
Normal file
236
crates/adapters/sqlite-federation/src/follow/followers.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowerWriter for SqliteFederationRepository {
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET
|
||||
status = excluded.status,
|
||||
follow_activity_id = excluded.follow_activity_id",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&created_at)
|
||||
.bind(follow_activity_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follower_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.flatten())
|
||||
}
|
||||
|
||||
async fn remove_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ? AND remote_actor_url = ?")
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowerStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = status_to_str(&status);
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowerReader for SqliteFederationRepository {
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: remote_actor_from_row(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url, f.status,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit as i64)
|
||||
.bind(offset as i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let status_str: String = row.get("status");
|
||||
Follower {
|
||||
actor: remote_actor_from_row(row, "remote_actor_url"),
|
||||
status: str_to_status(&status_str),
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'pending'",
|
||||
).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_accepted_follower_inboxes(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
) -> Result<Vec<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox
|
||||
FROM ap_followers f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||
AND f.remote_actor_url NOT IN (
|
||||
SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = ?
|
||||
)",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.filter_map(|r| r.try_get::<String, _>("inbox").ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_accepted_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_followers f
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
|
||||
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
124
crates/adapters/sqlite-federation/src/follow/following.rs
Normal file
124
crates/adapters/sqlite-federation/src/follow/following.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
|
||||
|
||||
use crate::{SqliteFederationRepository, remote_actor_from_row};
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowingWriter for SqliteFederationRepository {
|
||||
async fn add_following(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
actor: RemoteActor,
|
||||
follow_activity_id: &str,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let now = Utc::now().naive_utc();
|
||||
let created_at = datetime_to_str(&now);
|
||||
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_follow_activity_id(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let row: Option<Option<String>> = sqlx::query_scalar(
|
||||
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
|
||||
Ok(row.flatten())
|
||||
}
|
||||
|
||||
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
|
||||
.bind(&uid)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_following_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
remote_actor_url: &str,
|
||||
status: FollowingStatus,
|
||||
) -> Result<()> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = match status {
|
||||
FollowingStatus::Pending => "pending",
|
||||
FollowingStatus::Accepted => "accepted",
|
||||
};
|
||||
let result = sqlx::query(
|
||||
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(remote_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl FollowingReader for SqliteFederationRepository {
|
||||
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'",
|
||||
).bind(&uid).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| remote_actor_from_row(row, "url"))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
|
||||
let uid = local_user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_following_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>> {
|
||||
let uid = local_user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
|
||||
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
|
||||
FROM ap_following f
|
||||
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ? AND f.status = 'accepted'
|
||||
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
|
||||
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|row| remote_actor_from_row(row, "url"))
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
47
crates/adapters/sqlite-federation/src/follow/migration.rs
Normal file
47
crates/adapters/sqlite-federation/src/follow/migration.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use k_ap::FollowMigration;
|
||||
|
||||
use crate::SqliteFederationRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FollowMigration for SqliteFederationRepository {
|
||||
async fn migrate_follower_actor(
|
||||
&self,
|
||||
old_actor_url: &str,
|
||||
new_actor_url: &str,
|
||||
) -> Result<Vec<uuid::Uuid>> {
|
||||
let candidates: Vec<String> = sqlx::query_scalar(
|
||||
"SELECT local_user_id FROM ap_following
|
||||
WHERE remote_actor_url = ?1
|
||||
AND local_user_id NOT IN (
|
||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
|
||||
)",
|
||||
)
|
||||
.bind(old_actor_url)
|
||||
.bind(new_actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET remote_actor_url = ?1
|
||||
WHERE remote_actor_url = ?2
|
||||
AND local_user_id NOT IN (
|
||||
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
|
||||
)",
|
||||
)
|
||||
.bind(new_actor_url)
|
||||
.bind(old_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
3
crates/adapters/sqlite-federation/src/follow/mod.rs
Normal file
3
crates/adapters/sqlite-federation/src/follow/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod followers;
|
||||
mod following;
|
||||
mod migration;
|
||||
File diff suppressed because it is too large
Load Diff
110
crates/adapters/sqlite-federation/src/review.rs
Normal file
110
crates/adapters/sqlite-federation/src/review.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||
use anyhow::{Result, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use domain::models::{Review, ReviewSource};
|
||||
|
||||
use super::SqliteFederationRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
#[async_trait]
|
||||
impl RemoteReviewRepository for SqliteFederationRepository {
|
||||
async fn save_remote_review(
|
||||
&self,
|
||||
review: &Review,
|
||||
ap_id: &str,
|
||||
movie_title: &str,
|
||||
release_year: u16,
|
||||
external_metadata_id: Option<&str>,
|
||||
poster_url: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let actor_url = match review.source() {
|
||||
ReviewSource::Remote { actor_url } => actor_url.clone(),
|
||||
ReviewSource::Local => {
|
||||
return Err(anyhow!("save_remote_review called with a local review"));
|
||||
}
|
||||
};
|
||||
let movie_id = review.movie_id().value().to_string();
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
|
||||
VALUES (?, ?, ?, ?, NULL, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
external_metadata_id = COALESCE(excluded.external_metadata_id, movies.external_metadata_id),
|
||||
poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
|
||||
)
|
||||
.bind(&movie_id)
|
||||
.bind(external_metadata_id)
|
||||
.bind(movie_title)
|
||||
.bind(release_year.max(1888) as i64)
|
||||
.bind(poster_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let id = review.id().value().to_string();
|
||||
let user_id = review.user_id().value().to_string();
|
||||
let rating = review.rating().value() as i64;
|
||||
let comment = review.comment().map(|c| c.value().to_string());
|
||||
let watched_at = datetime_to_str(review.watched_at());
|
||||
let created_at = datetime_to_str(review.created_at());
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&movie_id)
|
||||
.bind(&user_id)
|
||||
.bind(rating)
|
||||
.bind(&comment)
|
||||
.bind(&watched_at)
|
||||
.bind(&created_at)
|
||||
.bind(&actor_url)
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE ap_id = ? AND remote_actor_url = ?")
|
||||
.bind(ap_id)
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
|
||||
let watched_at_str = datetime_to_str(&u.watched_at);
|
||||
sqlx::query(
|
||||
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
|
||||
WHERE ap_id = ? AND remote_actor_url = ?",
|
||||
)
|
||||
.bind(u.rating as i64)
|
||||
.bind(u.comment)
|
||||
.bind(&watched_at_str)
|
||||
.bind(u.watch_medium)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if let Some(url) = u.poster_url {
|
||||
sqlx::query(
|
||||
"UPDATE movies SET poster_path = ?
|
||||
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
|
||||
)
|
||||
.bind(url)
|
||||
.bind(u.ap_id)
|
||||
.bind(u.actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_by_actor(&self, actor_url: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM reviews WHERE remote_actor_url = ?")
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::ActorBlocklist;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use k_ap::DomainBlocklist;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use domain::ports::SocialQueryPort;
|
||||
use k_ap::AnnounceRepository;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
async fn test_pool() -> SqlitePool {
|
||||
@@ -47,88 +47,3 @@ async fn duplicate_announce_is_ignored() {
|
||||
.unwrap();
|
||||
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
||||
}
|
||||
|
||||
async fn setup_db(pool: &SqlitePool) {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
|
||||
url TEXT PRIMARY KEY,
|
||||
handle TEXT NOT NULL,
|
||||
inbox_url TEXT NOT NULL,
|
||||
shared_inbox_url TEXT,
|
||||
display_name TEXT,
|
||||
avatar_url TEXT,
|
||||
fetched_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS ap_following (
|
||||
local_user_id TEXT NOT NULL,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
follow_activity_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_accepted_following_urls_returns_only_accepted() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup_db(&pool).await;
|
||||
let repo = SqliteFederationRepository::new(pool.clone());
|
||||
let user_id = uuid::Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
|
||||
(?, 'https://other.social/users/bob', 'act2', 'pending')",
|
||||
)
|
||||
.bind(user_id.to_string())
|
||||
.bind(user_id.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let urls = repo.get_accepted_following_urls(user_id).await.unwrap();
|
||||
assert_eq!(urls.len(), 1);
|
||||
assert_eq!(urls[0], "https://other.social/users/alice");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_list_all_followed_remote_actors_deduplicates() {
|
||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||
setup_db(&pool).await;
|
||||
let repo = SqliteFederationRepository::new(pool.clone());
|
||||
let user1 = uuid::Uuid::new_v4();
|
||||
let user2 = uuid::Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_remote_actors (url, handle, inbox_url, fetched_at, display_name)
|
||||
VALUES ('https://other.social/users/alice', 'alice@other.social', 'https://other.social/inbox', '2024-01-01', 'Alice')",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
|
||||
(?, 'https://other.social/users/alice', 'act2', 'accepted')",
|
||||
)
|
||||
.bind(user1.to_string())
|
||||
.bind(user2.to_string())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let actors = repo.list_all_followed_remote_actors().await.unwrap();
|
||||
assert_eq!(actors.len(), 1);
|
||||
assert_eq!(actors[0].handle, "alice@other.social");
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
domain = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<d
|
||||
)
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SearchCommand for SqliteSearchAdapter {
|
||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
||||
@@ -86,7 +82,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(&movie_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
|
||||
@@ -104,7 +100,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(&language)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -118,7 +114,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(&person_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
|
||||
@@ -128,7 +124,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(person.known_for_department())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -144,7 +140,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
EntityType::Person => {
|
||||
sqlx::query(
|
||||
@@ -153,7 +149,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -205,7 +201,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
} else {
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
@@ -221,7 +217,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(query.filters.year.map(|y| y as i64))
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
@@ -249,7 +245,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, Row>(
|
||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||
@@ -270,7 +266,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
};
|
||||
let items = rows
|
||||
.into_iter()
|
||||
@@ -321,7 +317,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(&fts_query)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
count as u64
|
||||
};
|
||||
|
||||
@@ -346,7 +342,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
let mut items = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
@@ -355,7 +351,7 @@ impl SqliteSearchAdapter {
|
||||
.bind(&row.person_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(map_err)?
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
.flatten();
|
||||
|
||||
let known_for_titles = if let Some(tid) = tmdb_id {
|
||||
|
||||
15
crates/adapters/sqlite-social/Cargo.toml
Normal file
15
crates/adapters/sqlite-social/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "sqlite-social"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sqlx = { workspace = true }
|
||||
adapter-common = { workspace = true }
|
||||
domain = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
272
crates/adapters/sqlite-social/src/ap_content.rs
Normal file
272
crates/adapters/sqlite-social/src/ap_content.rs
Normal file
@@ -0,0 +1,272 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
models::{
|
||||
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||
WatchlistWithMovie,
|
||||
},
|
||||
ports::LocalApContentQuery,
|
||||
value_objects::{
|
||||
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
||||
ReviewId, UserId, WatchlistEntryId,
|
||||
},
|
||||
};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct SqliteApContentQuery {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteApContentQuery {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local row types ──────────────────────────────────────────────────────────
|
||||
|
||||
use adapter_common::{parse_datetime, parse_uuid};
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct MovieRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl MovieRow {
|
||||
fn into_domain(self) -> Result<Movie, DomainError> {
|
||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
||||
let external_metadata_id = self
|
||||
.external_metadata_id
|
||||
.map(ExternalMetadataId::new)
|
||||
.transpose()?;
|
||||
let title = MovieTitle::new(self.title)?;
|
||||
let release_year = ReleaseYear::new(self.release_year as u16)?;
|
||||
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
|
||||
Ok(Movie::from_persistence(
|
||||
id,
|
||||
external_metadata_id,
|
||||
title,
|
||||
release_year,
|
||||
self.director,
|
||||
poster_path,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ReviewRow {
|
||||
id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl ReviewRow {
|
||||
fn into_domain(self) -> Result<Review, DomainError> {
|
||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
||||
let rating = Rating::new(self.rating as u8)?;
|
||||
let comment = self.comment.map(Comment::new).transpose()?;
|
||||
let watched_at = parse_datetime(&self.watched_at)?;
|
||||
let created_at = parse_datetime(&self.created_at)?;
|
||||
let source = match self.remote_actor_url {
|
||||
None => ReviewSource::Local,
|
||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
||||
};
|
||||
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
|
||||
Ok(Review::from_persistence(PersistedReview {
|
||||
id,
|
||||
movie_id,
|
||||
user_id,
|
||||
rating,
|
||||
comment,
|
||||
watched_at,
|
||||
created_at,
|
||||
source,
|
||||
watch_medium,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DiaryRow {
|
||||
id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
review_id: String,
|
||||
movie_id: String,
|
||||
user_id: String,
|
||||
rating: i64,
|
||||
comment: Option<String>,
|
||||
watched_at: String,
|
||||
created_at: String,
|
||||
remote_actor_url: Option<String>,
|
||||
watch_medium: Option<String>,
|
||||
}
|
||||
|
||||
impl DiaryRow {
|
||||
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||
let movie = MovieRow {
|
||||
id: self.id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
let review = ReviewRow {
|
||||
id: self.review_id,
|
||||
movie_id: self.movie_id,
|
||||
user_id: self.user_id,
|
||||
rating: self.rating,
|
||||
comment: self.comment,
|
||||
watched_at: self.watched_at,
|
||||
created_at: self.created_at,
|
||||
remote_actor_url: self.remote_actor_url,
|
||||
watch_medium: self.watch_medium,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(DiaryEntry::new(movie, review))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct WatchlistRow {
|
||||
id: String,
|
||||
user_id: String,
|
||||
movie_id: String,
|
||||
added_at: String,
|
||||
m_id: String,
|
||||
external_metadata_id: Option<String>,
|
||||
title: String,
|
||||
release_year: i64,
|
||||
director: Option<String>,
|
||||
poster_path: Option<String>,
|
||||
}
|
||||
|
||||
impl WatchlistRow {
|
||||
fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
|
||||
let entry = WatchlistEntry {
|
||||
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
|
||||
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
|
||||
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
|
||||
added_at: parse_datetime(&self.added_at)?,
|
||||
};
|
||||
let movie = MovieRow {
|
||||
id: self.m_id,
|
||||
external_metadata_id: self.external_metadata_id,
|
||||
title: self.title,
|
||||
release_year: self.release_year,
|
||||
director: self.director,
|
||||
poster_path: self.poster_path,
|
||||
}
|
||||
.into_domain()?;
|
||||
Ok(WatchlistWithMovie { entry, movie })
|
||||
}
|
||||
}
|
||||
|
||||
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
|
||||
|
||||
#[async_trait]
|
||||
impl LocalApContentQuery for SqliteApContentQuery {
|
||||
async fn get_local_watchlist_for_user(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
) -> Result<Vec<WatchlistWithMovie>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let rows: Vec<WatchlistRow> = sqlx::query_as(
|
||||
"SELECT w.id, w.user_id, w.movie_id, w.added_at,
|
||||
m.id AS m_id, m.external_metadata_id, m.title, m.release_year,
|
||||
m.director, m.poster_path
|
||||
FROM watchlist_entries w
|
||||
JOIN movies m ON m.id = w.movie_id
|
||||
WHERE w.user_id = ?
|
||||
ORDER BY w.added_at DESC",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.into_iter().map(WatchlistRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_for_movie(
|
||||
&self,
|
||||
movie_id: &MovieId,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let mid = movie_id.value().to_string();
|
||||
let rows = sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.created_at DESC",
|
||||
)
|
||||
.bind(&mid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
|
||||
async fn get_local_reviews_page(
|
||||
&self,
|
||||
user_id: &UserId,
|
||||
before: Option<chrono::NaiveDateTime>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<DiaryEntry>, DomainError> {
|
||||
let uid = user_id.value().to_string();
|
||||
let limit_i64 = limit as i64;
|
||||
|
||||
let rows = if let Some(before_ts) = before {
|
||||
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(&ts)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
} else {
|
||||
sqlx::query_as::<_, DiaryRow>(
|
||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
||||
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
|
||||
FROM reviews r
|
||||
INNER JOIN movies m ON m.id = r.movie_id
|
||||
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
|
||||
ORDER BY r.watched_at DESC
|
||||
LIMIT ?",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(adapter_common::map_sqlx_error)?
|
||||
};
|
||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
||||
}
|
||||
}
|
||||
58
crates/adapters/sqlite-social/src/federated_profile.rs
Normal file
58
crates/adapters/sqlite-social/src/federated_profile.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||
use sqlx::Row;
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederatedProfileQuery for SqliteSocialRepository {
|
||||
async fn get_federated_profile(
|
||||
&self,
|
||||
synthetic_user_id: uuid::Uuid,
|
||||
) -> Result<Option<FederatedProfile>, DomainError> {
|
||||
let uid = synthetic_user_id.to_string();
|
||||
|
||||
let actor_url: Option<String> = sqlx::query_scalar(
|
||||
"SELECT remote_actor_url FROM reviews
|
||||
WHERE user_id = ? AND remote_actor_url IS NOT NULL
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
let actor_url = match actor_url {
|
||||
Some(url) => url,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT handle, display_name, bio, avatar_url, banner_url
|
||||
FROM ap_remote_actors WHERE url = ?",
|
||||
)
|
||||
.bind(&actor_url)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
|
||||
match row {
|
||||
Some(r) => Ok(Some(FederatedProfile {
|
||||
actor_url,
|
||||
handle: r.get("handle"),
|
||||
display_name: r.try_get("display_name").ok().flatten(),
|
||||
bio: r.try_get("bio").ok().flatten(),
|
||||
avatar_url: r.try_get("avatar_url").ok().flatten(),
|
||||
banner_url: r.try_get("banner_url").ok().flatten(),
|
||||
})),
|
||||
None => Ok(Some(FederatedProfile {
|
||||
handle: actor_url.clone(),
|
||||
actor_url,
|
||||
display_name: None,
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
357
crates/adapters/sqlite-social/src/follow_repository.rs
Normal file
357
crates/adapters/sqlite-social/src/follow_repository.rs
Normal file
@@ -0,0 +1,357 @@
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use domain::{
|
||||
errors::DomainError,
|
||||
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
||||
};
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::SqliteSocialRepository;
|
||||
use adapter_common::datetime_to_str;
|
||||
|
||||
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||
match status {
|
||||
FollowStatus::Pending => "pending",
|
||||
FollowStatus::Accepted => "accepted",
|
||||
FollowStatus::Rejected => "rejected",
|
||||
}
|
||||
}
|
||||
|
||||
fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
||||
DomainError::InfrastructureError(e.to_string())
|
||||
}
|
||||
|
||||
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
|
||||
match status {
|
||||
"pending" => Some(FollowStatus::Pending),
|
||||
"accepted" => Some(FollowStatus::Accepted),
|
||||
"rejected" => Some(FollowStatus::Rejected),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowCommand for SqliteSocialRepository {
|
||||
async fn add_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
|
||||
VALUES (?1, ?2, '', ?3, ?4)
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.bind(&now)
|
||||
.bind(status_str)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follow_status(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follow(
|
||||
&self,
|
||||
follower_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = follower_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_follower(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
let now = datetime_to_str(&Utc::now().naive_utc());
|
||||
sqlx::query(
|
||||
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
|
||||
VALUES (?1, ?2, ?3, ?4, '')
|
||||
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.bind(status_str)
|
||||
.bind(&now)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
status: FollowStatus,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
let status_str = follow_status_to_str(&status);
|
||||
sqlx::query(
|
||||
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
|
||||
)
|
||||
.bind(status_str)
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_follower_record(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
follower_actor_url: &str,
|
||||
) -> Result<(), DomainError> {
|
||||
let uid = local_user_id.to_string();
|
||||
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
|
||||
.bind(&uid)
|
||||
.bind(follower_actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn social_actor_from_row(
|
||||
row: &sqlx::sqlite::SqliteRow,
|
||||
instance: &InstanceIdentity,
|
||||
) -> SocialActor {
|
||||
let actor_url: String = row.get("remote_actor_url");
|
||||
let identity = instance.identify(&actor_url);
|
||||
|
||||
let (handle, display_name, avatar_url) = match &identity {
|
||||
SocialIdentity::Local(_) => {
|
||||
let username: Option<String> = row.try_get("local_username").ok().flatten();
|
||||
let display: Option<String> = row.try_get("local_display").ok().flatten();
|
||||
let avatar: Option<String> = row
|
||||
.try_get::<Option<String>, _>("local_avatar_path")
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|p| instance.image_url_for(&p));
|
||||
let handle = username
|
||||
.as_deref()
|
||||
.map(|u| instance.handle_for(u))
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
(handle, display, avatar)
|
||||
}
|
||||
SocialIdentity::Remote { .. } => {
|
||||
let handle: String = row
|
||||
.try_get::<Option<String>, _>("remote_handle")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| actor_url.clone());
|
||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||
(handle, display, avatar)
|
||||
}
|
||||
};
|
||||
|
||||
SocialActor {
|
||||
identity,
|
||||
handle,
|
||||
display_name,
|
||||
avatar_url,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
||||
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_followers(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_followers f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn get_pending_following(
|
||||
&self,
|
||||
user_id: uuid::Uuid,
|
||||
) -> Result<Vec<SocialActor>, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let rows = sqlx::query(
|
||||
"SELECT f.remote_actor_url,
|
||||
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
|
||||
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
|
||||
FROM ap_following f
|
||||
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
|
||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||
)
|
||||
.bind(self.instance.base_url())
|
||||
.bind(&uid)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| social_actor_from_row(r, &self.instance))
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||
let uid = user_id.to_string();
|
||||
let count: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'pending'",
|
||||
)
|
||||
.bind(&uid)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
async fn get_relation(
|
||||
&self,
|
||||
viewer_id: uuid::Uuid,
|
||||
target_actor_url: &str,
|
||||
) -> Result<FollowRelation, DomainError> {
|
||||
let uid = viewer_id.to_string();
|
||||
let row = sqlx::query(
|
||||
"SELECT (SELECT status FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS following,
|
||||
(SELECT status FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2) AS followed_by",
|
||||
)
|
||||
.bind(&uid)
|
||||
.bind(target_actor_url)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(infra_err)?;
|
||||
|
||||
Ok(FollowRelation {
|
||||
following: row
|
||||
.try_get::<Option<String>, _>("following")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
followed_by: row
|
||||
.try_get::<Option<String>, _>("followed_by")
|
||||
.map_err(infra_err)?
|
||||
.as_deref()
|
||||
.and_then(follow_status_from_str),
|
||||
})
|
||||
}
|
||||
}
|
||||
42
crates/adapters/sqlite-social/src/lib.rs
Normal file
42
crates/adapters/sqlite-social/src/lib.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
mod federated_profile;
|
||||
mod follow_repository;
|
||||
mod social;
|
||||
mod watchlist;
|
||||
|
||||
pub mod ap_content;
|
||||
pub mod remote_goals;
|
||||
|
||||
pub use ap_content::SqliteApContentQuery;
|
||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// SQLite-backed implementations of the *domain* social ports.
|
||||
///
|
||||
/// Deliberately separate from `sqlite-federation`: this crate knows nothing
|
||||
/// about ActivityPub, which is what allows a build with the `federation`
|
||||
/// feature off to exclude the federation stack entirely. See ADR-0009.
|
||||
///
|
||||
/// Shares the `ap_followers` / `ap_following` tables with
|
||||
/// `sqlite-federation`; neither crate owns migrations.
|
||||
pub struct SqliteSocialRepository {
|
||||
pub(crate) pool: SqlitePool,
|
||||
pub(crate) instance: domain::value_objects::InstanceIdentity,
|
||||
}
|
||||
|
||||
impl SqliteSocialRepository {
|
||||
pub fn new(pool: SqlitePool, instance: domain::value_objects::InstanceIdentity) -> Self {
|
||||
Self { pool, instance }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_federated_profile_query(
|
||||
pool: SqlitePool,
|
||||
instance: domain::value_objects::InstanceIdentity,
|
||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
||||
std::sync::Arc::new(SqliteSocialRepository::new(pool, instance))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/follow_relation_tests.rs"]
|
||||
mod follow_relation_tests;
|
||||
@@ -11,11 +11,6 @@ impl SqliteRemoteGoalRepository {
|
||||
pub fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
fn map_err(e: sqlx::Error) -> DomainError {
|
||||
tracing::error!("Database error: {:?}", e);
|
||||
DomainError::InfrastructureError("Database operation failed".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -36,7 +31,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
.bind(&received)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -53,7 +48,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
.bind(ap_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -64,7 +59,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -74,7 +69,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -87,7 +82,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
||||
.bind(actor_url)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(Self::map_err)?;
|
||||
.map_err(adapter_common::map_sqlx_error)?;
|
||||
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
27
crates/adapters/sqlite-social/src/social.rs
Normal file
27
crates/adapters/sqlite-social/src/social.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use async_trait::async_trait;
|
||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||
|
||||
use super::SqliteSocialRepository;
|
||||
|
||||
#[async_trait]
|
||||
impl FederationAdminQuery for SqliteSocialRepository {
|
||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||
FROM ap_remote_actors ar
|
||||
JOIN ap_following f ON f.remote_actor_url = ar.url
|
||||
WHERE f.status = 'accepted'",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(url, handle, display_name)| RemoteActorInfo {
|
||||
url,
|
||||
handle,
|
||||
display_name,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user