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
|
# PORT=3000
|
||||||
# RATE_LIMIT=60
|
# RATE_LIMIT=60
|
||||||
# SECURE_COOKIES=true
|
# 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 (for SPA development only)
|
||||||
# CORS_ORIGINS=http://localhost:5173
|
# CORS_ORIGINS=http://localhost:5173
|
||||||
|
|||||||
@@ -40,3 +40,8 @@ jobs:
|
|||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
run: cargo 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
|
- name: test
|
||||||
run: cargo 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:
|
docker:
|
||||||
name: Build & Push Docker Image
|
name: Build & Push Docker Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -57,8 +62,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||||
|
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: meta
|
id: meta
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,7 +13,8 @@
|
|||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/
|
docs/*
|
||||||
|
!docs/adr/
|
||||||
|
|
||||||
imgs/
|
imgs/
|
||||||
.sqlx/
|
.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:
|
4. Run the backend and worker:
|
||||||
|
|
||||||
```bash
|
```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)
|
cargo run -p worker # event worker (separate terminal)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture
|
|||||||
**Key rules:**
|
**Key rules:**
|
||||||
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
|
- 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/`)
|
- 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
|
domain → pure types, traits (ports), zero deps
|
||||||
|
|||||||
172
Cargo.lock
generated
172
Cargo.lock
generated
@@ -220,6 +220,17 @@ dependencies = [
|
|||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "adapter-common"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"sqlx",
|
||||||
|
"tracing",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -293,7 +304,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
name = "api-types"
|
name = "api-types"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -319,8 +332,8 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
"hex",
|
"hex",
|
||||||
|
"infra-wiring",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
"reqwest 0.13.3",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -1106,6 +1119,39 @@ dependencies = [
|
|||||||
"static_assertions",
|
"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]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -1607,7 +1653,9 @@ dependencies = [
|
|||||||
"email_address",
|
"email_address",
|
||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2701,6 +2749,14 @@ dependencies = [
|
|||||||
"cfb",
|
"cfb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infra-wiring"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"sqlx",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2871,9 +2927,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.4.0"
|
version = "0.5.0"
|
||||||
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||||
checksum = "ccaa914953bfd45ea206e11826da8f61ce1fbe02f8fe0622880527046ad6ae24"
|
checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
@@ -2882,9 +2938,11 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"enum_delegate",
|
"enum_delegate",
|
||||||
"futures",
|
"futures",
|
||||||
|
"paste",
|
||||||
"reqwest 0.13.3",
|
"reqwest 0.13.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -3874,6 +3932,7 @@ dependencies = [
|
|||||||
name = "postgres"
|
name = "postgres"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3881,6 +3940,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
|
"postgres-social",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -3909,11 +3969,13 @@ name = "postgres-federation"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
|
"adapter-common",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
|
"postgres-social",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3924,12 +3986,25 @@ dependencies = [
|
|||||||
name = "postgres-search"
|
name = "postgres-search"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "postgres-social"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"sqlx",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -3964,42 +4039,22 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
|
|||||||
name = "presentation"
|
name = "presentation"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"api-types",
|
"api-types",
|
||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auth",
|
|
||||||
"axum",
|
"axum",
|
||||||
"axum-governor",
|
"axum-governor",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"composition",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"export",
|
|
||||||
"futures",
|
"futures",
|
||||||
"http-body-util",
|
|
||||||
"importer",
|
|
||||||
"infer",
|
"infer",
|
||||||
"jellyfin",
|
|
||||||
"metadata",
|
|
||||||
"nats",
|
|
||||||
"object-storage",
|
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plex",
|
|
||||||
"poster-fetcher",
|
|
||||||
"postgres",
|
|
||||||
"postgres-event-queue",
|
|
||||||
"postgres-federation",
|
|
||||||
"postgres-search",
|
|
||||||
"rss 0.1.0",
|
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlite",
|
|
||||||
"sqlite-event-queue",
|
|
||||||
"sqlite-federation",
|
|
||||||
"sqlite-search",
|
|
||||||
"sqlx",
|
|
||||||
"template-askama",
|
"template-askama",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -4586,7 +4641,6 @@ dependencies = [
|
|||||||
name = "rss"
|
name = "rss"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
|
||||||
"domain",
|
"domain",
|
||||||
"rss 2.0.13",
|
"rss 2.0.13",
|
||||||
]
|
]
|
||||||
@@ -4920,6 +4974,50 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -5153,6 +5251,7 @@ dependencies = [
|
|||||||
name = "sqlite"
|
name = "sqlite"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5162,6 +5261,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlite-social",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5188,12 +5288,14 @@ name = "sqlite-federation"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub",
|
"activitypub",
|
||||||
|
"adapter-common",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlite-social",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5204,6 +5306,7 @@ dependencies = [
|
|||||||
name = "sqlite-search"
|
name = "sqlite-search"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"domain",
|
"domain",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -5211,6 +5314,19 @@ dependencies = [
|
|||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlite-social"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"adapter-common",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"domain",
|
||||||
|
"sqlx",
|
||||||
|
"tokio",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlx"
|
name = "sqlx"
|
||||||
version = "0.8.6"
|
version = "0.8.6"
|
||||||
@@ -5595,7 +5711,7 @@ dependencies = [
|
|||||||
name = "template-askama"
|
name = "template-askama"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"application",
|
"api-types",
|
||||||
"askama",
|
"askama",
|
||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
@@ -6685,7 +6801,7 @@ version = "0.1.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.48.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7120,11 +7236,13 @@ dependencies = [
|
|||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auth",
|
"auth",
|
||||||
|
"composition",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"export",
|
"export",
|
||||||
"image-converter",
|
"image-converter",
|
||||||
"importer",
|
"importer",
|
||||||
|
"infra-wiring",
|
||||||
"metadata",
|
"metadata",
|
||||||
"nats",
|
"nats",
|
||||||
"object-storage",
|
"object-storage",
|
||||||
|
|||||||
12
Cargo.toml
12
Cargo.toml
@@ -10,7 +10,9 @@ members = [
|
|||||||
"crates/adapters/sqlite",
|
"crates/adapters/sqlite",
|
||||||
"crates/adapters/postgres",
|
"crates/adapters/postgres",
|
||||||
"crates/adapters/sqlite-federation",
|
"crates/adapters/sqlite-federation",
|
||||||
|
"crates/adapters/sqlite-social",
|
||||||
"crates/adapters/postgres-federation",
|
"crates/adapters/postgres-federation",
|
||||||
|
"crates/adapters/postgres-social",
|
||||||
"crates/adapters/sqlite-event-queue",
|
"crates/adapters/sqlite-event-queue",
|
||||||
"crates/adapters/postgres-event-queue",
|
"crates/adapters/postgres-event-queue",
|
||||||
"crates/adapters/template-askama",
|
"crates/adapters/template-askama",
|
||||||
@@ -23,7 +25,9 @@ members = [
|
|||||||
"crates/adapters/tmdb-enrichment",
|
"crates/adapters/tmdb-enrichment",
|
||||||
"crates/adapters/image-converter",
|
"crates/adapters/image-converter",
|
||||||
"crates/domain",
|
"crates/domain",
|
||||||
|
"crates/composition",
|
||||||
"crates/presentation",
|
"crates/presentation",
|
||||||
|
"crates/server",
|
||||||
"crates/tui",
|
"crates/tui",
|
||||||
"crates/worker",
|
"crates/worker",
|
||||||
"crates/adapters/importer",
|
"crates/adapters/importer",
|
||||||
@@ -31,6 +35,8 @@ members = [
|
|||||||
"crates/adapters/plex",
|
"crates/adapters/plex",
|
||||||
"crates/adapters/sqlite-search",
|
"crates/adapters/sqlite-search",
|
||||||
"crates/adapters/postgres-search",
|
"crates/adapters/postgres-search",
|
||||||
|
"crates/adapters/adapter-common",
|
||||||
|
"crates/infra-wiring",
|
||||||
]
|
]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
@@ -66,6 +72,7 @@ api-types = { path = "crates/api-types" }
|
|||||||
domain = { path = "crates/domain" }
|
domain = { path = "crates/domain" }
|
||||||
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
|
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
|
||||||
application = { path = "crates/application" }
|
application = { path = "crates/application" }
|
||||||
|
composition = { path = "crates/composition" }
|
||||||
presentation = { path = "crates/presentation" }
|
presentation = { path = "crates/presentation" }
|
||||||
auth = { path = "crates/adapters/auth" }
|
auth = { path = "crates/adapters/auth" }
|
||||||
metadata = { path = "crates/adapters/metadata" }
|
metadata = { path = "crates/adapters/metadata" }
|
||||||
@@ -77,8 +84,10 @@ rss = { path = "crates/adapters/rss" }
|
|||||||
export = { path = "crates/adapters/export" }
|
export = { path = "crates/adapters/export" }
|
||||||
sqlite = { path = "crates/adapters/sqlite" }
|
sqlite = { path = "crates/adapters/sqlite" }
|
||||||
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
||||||
|
sqlite-social = { path = "crates/adapters/sqlite-social" }
|
||||||
postgres = { path = "crates/adapters/postgres" }
|
postgres = { path = "crates/adapters/postgres" }
|
||||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||||
|
postgres-social = { path = "crates/adapters/postgres-social" }
|
||||||
template-askama = { path = "crates/adapters/template-askama" }
|
template-askama = { path = "crates/adapters/template-askama" }
|
||||||
activitypub = { path = "crates/adapters/activitypub" }
|
activitypub = { path = "crates/adapters/activitypub" }
|
||||||
event-payload = { path = "crates/adapters/event-payload" }
|
event-payload = { path = "crates/adapters/event-payload" }
|
||||||
@@ -91,10 +100,11 @@ plex = { path = "crates/adapters/plex" }
|
|||||||
image-converter = { path = "crates/adapters/image-converter" }
|
image-converter = { path = "crates/adapters/image-converter" }
|
||||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
||||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||||
|
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||||
|
infra-wiring = { path = "crates/infra-wiring" }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
debug = 1 # line tables only — still debuggable, much faster linking
|
debug = 1 # line tables only — still debuggable, much faster linking
|
||||||
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
|
|
||||||
|
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 2 # compile deps faster at runtime; paid once, cached after
|
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
|
WORKDIR /build
|
||||||
|
|
||||||
# Cache dependency compilation separately from source
|
# 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.toml Cargo.lock ./
|
||||||
COPY .cargo ./.cargo
|
COPY .cargo ./.cargo
|
||||||
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
|
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/rss/Cargo.toml crates/adapters/rss/Cargo.toml
|
||||||
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/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-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/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/Cargo.toml crates/adapters/postgres/Cargo.toml
|
||||||
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/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/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/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
|
||||||
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
|
||||||
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
COPY crates/application/Cargo.toml crates/application/Cargo.toml
|
||||||
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/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/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/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/tui/Cargo.toml crates/tui/Cargo.toml
|
||||||
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/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/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/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/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
|
# Stub every crate so cargo can resolve and fetch deps
|
||||||
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
|
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):
|
# To add NATS support (EVENT_BUS_BACKEND=nats):
|
||||||
# --build-arg FEATURES=sqlite,sqlite-federation,nats
|
# --build-arg FEATURES=sqlite,sqlite-federation,nats
|
||||||
ARG FEATURES=sqlite,sqlite-federation
|
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 -----
|
# ----- runtime -----
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -83,13 +93,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
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 --from=builder /build/target/release/worker ./worker
|
||||||
COPY static ./static
|
COPY static ./static
|
||||||
COPY --from=spa-builder /spa/dist ./spa/dist
|
COPY --from=spa-builder /spa/dist ./spa/dist
|
||||||
|
|
||||||
EXPOSE 3000
|
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
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
# Run the full local check suite — same order as CI would.
|
# 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"
|
@echo "✅ All checks passed"
|
||||||
|
|
||||||
# Enforce that no application use case imports AppContext (god-object guard).
|
# Enforce that no application use case imports AppContext (god-object guard).
|
||||||
@@ -13,6 +13,162 @@ check-appcontext:
|
|||||||
fi
|
fi
|
||||||
@echo "✅ No AppContext in application crate"
|
@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.
|
# Apply rustfmt to all files.
|
||||||
fmt:
|
fmt:
|
||||||
cargo fmt
|
cargo fmt
|
||||||
@@ -34,4 +190,4 @@ fix:
|
|||||||
cargo fmt
|
cargo fmt
|
||||||
cargo clippy --fix --allow-dirty --allow-staged
|
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
|
## Features
|
||||||
|
|
||||||
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 0–5 rating
|
- 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)
|
||||||
- Immutable append-only viewing ledger (tracks re-watches)
|
- 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)
|
- 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`
|
- 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
|
- 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
|
- 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)
|
- RSS/Atom feed for public subscription (global and per-user)
|
||||||
- JWT authentication via cookie (HTML) or Bearer token (REST API)
|
- 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)
|
- 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` activity, delivery filter excludes blocked actors and blocked-domain inboxes
|
- 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
|
- 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`
|
- 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)
|
- 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
|
- 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
|
- 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
|
- 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:
|
Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
||||||
|
|
||||||
```
|
```
|
||||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
|
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
||||||
domain — pure types and trait definitions, no external deps
|
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries
|
||||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
|
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
|
||||||
presentation — Axum HTTP router, OpenAPI spec assembly, Swagger UI + Scalar serving, composition root for the HTTP process
|
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)
|
worker — standalone worker binary (event consumer, poster sync, federation)
|
||||||
adapters/
|
adapters/
|
||||||
|
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
|
||||||
auth — JWT issuance and validation (Argon2 passwords)
|
auth — JWT issuance and validation (Argon2 passwords)
|
||||||
sqlite — SQLite repository + connection factory
|
sqlite — SQLite repository + connection factory
|
||||||
postgres — PostgreSQL repository + connection factory
|
postgres — PostgreSQL repository + connection factory
|
||||||
metadata — TMDB / OMDb HTTP client
|
metadata — TMDB / OMDb HTTP client
|
||||||
poster-fetcher — downloads poster images
|
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
|
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
|
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
|
template-askama — Askama HTML rendering
|
||||||
rss — RSS/Atom feed generation
|
rss — RSS/Atom feed generation
|
||||||
export — CSV and JSON diary serialization
|
export — CSV and JSON diary serialization
|
||||||
@@ -112,6 +116,7 @@ adapters/
|
|||||||
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
event-payload — shared event serialization DTOs (used by all event bus adapters)
|
||||||
sqlite-event-queue — durable polling event queue backed by SQLite
|
sqlite-event-queue — durable polling event queue backed by SQLite
|
||||||
postgres-event-queue — durable polling event queue backed by PostgreSQL
|
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
|
nats — NATS Core / JetStream event publisher and consumer
|
||||||
event-publisher — in-memory event channel (used in tests)
|
event-publisher — in-memory event channel (used in tests)
|
||||||
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals
|
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 |
|
| `RATE_LIMIT` | `60` | No | Requests per minute per IP |
|
||||||
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
|
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
|
||||||
| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
|
| `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 |
|
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
|
||||||
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
|
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
|
||||||
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
|
| `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
|
```bash
|
||||||
cargo run -p worker
|
cargo run -p worker
|
||||||
@@ -169,11 +174,11 @@ cargo run -p worker
|
|||||||
## Run
|
## Run
|
||||||
|
|
||||||
```bash
|
```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)
|
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
|
## API
|
||||||
|
|
||||||
@@ -245,7 +250,7 @@ This builds and starts the HTTP server (port 3000) and event worker. Data is per
|
|||||||
|
|
||||||
### Manual docker run
|
### 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
|
```bash
|
||||||
docker build -t movies-diary .
|
docker build -t movies-diary .
|
||||||
|
|||||||
@@ -14,29 +14,35 @@ graph TB
|
|||||||
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
|
||||||
subgraph UseCases["Use Cases"]
|
subgraph UseCases["Use Cases"]
|
||||||
UC_AUTH["auth<br/>login, register"]
|
UC_AUTH["auth<br/>login, register"]
|
||||||
UC_DIARY["diary<br/>log_review, get_diary,<br/>get_activity_feed, export"]
|
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"]
|
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_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_WATCHLIST["watchlist<br/>add, remove, get"]
|
||||||
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
|
UC_WRAPUP["wrapup<br/>generate, compute,<br/>list, delete"]
|
||||||
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
|
UC_GOALS["goals<br/>create, update, delete,<br/>get, list"]
|
||||||
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
|
||||||
UC_SEARCH["search<br/>execute"]
|
UC_SEARCH["search<br/>execute"]
|
||||||
UC_PERSON["person<br/>get, get_credits"]
|
UC_PERSON["person<br/>get, get_credits"]
|
||||||
|
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
|
||||||
end
|
end
|
||||||
subgraph EventHandlers["Event Handlers"]
|
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_DISCOVER["MovieDiscoveryIndexer"]
|
||||||
EH_CLEANUP["SearchCleanupHandler"]
|
EH_CLEANUP["SearchCleanupHandler"]
|
||||||
EH_REINDEX["SearchReindexHandler"]
|
EH_REINDEX["SearchReindexHandler"]
|
||||||
EH_WRAPUP["WrapUpEventHandler"]
|
EH_WRAPUP["WrapUpEventHandler"]
|
||||||
|
EH_AP["ActivityPubEventHandler<br/><i>reviews, watchlist, goals,<br/>UserDeleted, UserAccountMoved</i>"]
|
||||||
end
|
end
|
||||||
subgraph Jobs["Periodic Jobs"]
|
subgraph Jobs["Periodic Jobs"]
|
||||||
JOB_IMPORT["ImportSessionCleanup"]
|
JOB_IMPORT["ImportSessionCleanup"]
|
||||||
JOB_WATCH["WatchEventCleanup"]
|
JOB_WATCH["WatchEventCleanup"]
|
||||||
JOB_STALE["EnrichmentStaleness"]
|
JOB_STALE["EnrichmentStaleness"]
|
||||||
JOB_WRAPGEN["WrapUpAutoGenerate"]
|
JOB_WRAPGEN["WrapUpAutoGenerate"]
|
||||||
|
JOB_WRAPCLEAN["WrapUpCleanup"]
|
||||||
|
JOB_SESSION["RefreshSessionCleanup"]
|
||||||
|
JOB_DEDUP["MovieDeduplication<br/><i>merge duplicate movie<br/>records (daily)</i>"]
|
||||||
end
|
end
|
||||||
WORKER_SVC["WorkerService<br/><i>Semaphore(8), JoinSet,<br/>shutdown signal</i>"]
|
WORKER_SVC["WorkerService<br/><i>Semaphore(8), JoinSet,<br/>shutdown signal</i>"]
|
||||||
end
|
end
|
||||||
@@ -45,7 +51,7 @@ graph TB
|
|||||||
direction TB
|
direction TB
|
||||||
subgraph Models["Models"]
|
subgraph Models["Models"]
|
||||||
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
|
||||||
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
|
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
|
||||||
M_USER["User, UserSummary"]
|
M_USER["User, UserSummary"]
|
||||||
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
M_PERSON["Person, PersonId,<br/>PersonCredits"]
|
||||||
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
|
||||||
@@ -54,25 +60,30 @@ graph TB
|
|||||||
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
M_SEARCH["SearchQuery,<br/>SearchResults"]
|
||||||
end
|
end
|
||||||
subgraph Ports["Port Traits (Interfaces)"]
|
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_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<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
|
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<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
|
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
|
||||||
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
|
||||||
end
|
end
|
||||||
subgraph DomainServices["Services (pure, no I/O)"]
|
subgraph DomainServices["Services (pure, no I/O)"]
|
||||||
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
|
||||||
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
|
||||||
end
|
end
|
||||||
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>SearchReindexRequested, ...</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/>Email, Username, Password, ...</i>"]
|
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph ApiTypes["api-types (0 domain deps)"]
|
subgraph ApiTypes["api-types"]
|
||||||
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"]
|
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
|
end
|
||||||
|
|
||||||
subgraph Adapters["Adapters (implement Port Traits)"]
|
subgraph Adapters["Adapters (implement Port Traits)"]
|
||||||
direction TB
|
direction TB
|
||||||
|
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
|
||||||
subgraph Storage["Storage"]
|
subgraph Storage["Storage"]
|
||||||
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
|
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
|
||||||
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
|
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
|
||||||
@@ -82,7 +93,9 @@ graph TB
|
|||||||
end
|
end
|
||||||
subgraph Messaging["Messaging"]
|
subgraph Messaging["Messaging"]
|
||||||
A_NATS["nats<br/><i>JetStream / Core</i>"]
|
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_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>"]
|
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
|
||||||
end
|
end
|
||||||
subgraph External["External Services"]
|
subgraph External["External Services"]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { version = "0.4.0", registry = "gitea" }
|
k-ap = { version = "0.5.0", registry = "gitea" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use k_ap::{ApContentReader, ApObjectHandler};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -21,12 +20,34 @@ impl ApContentReader for CompositeObjectHandler {
|
|||||||
async fn get_local_objects_page(
|
async fn get_local_objects_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
before: Option<DateTime<Utc>>,
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
|
) -> anyhow::Result<Vec<LocalObject>> {
|
||||||
self.review
|
let fetch_limit = limit * 3;
|
||||||
.get_local_objects_page(user_id, before, limit)
|
let reviews = self
|
||||||
.await
|
.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> {
|
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||||
@@ -42,10 +63,14 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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)
|
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);
|
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
|
self.review.on_create(ap_id, actor_url, object).await
|
||||||
} else if is_goal {
|
} else if is_goal {
|
||||||
self.goal.on_create(ap_id, actor_url, object).await
|
self.goal.on_create(ap_id, actor_url, object).await
|
||||||
@@ -63,11 +88,16 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
actor_url: &Url,
|
actor_url: &Url,
|
||||||
object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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);
|
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
|
self.review.on_update(ap_id, actor_url, object).await
|
||||||
} else if is_goal {
|
} else if is_goal {
|
||||||
self.goal.on_update(ap_id, actor_url, object).await
|
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 {
|
} else {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -87,36 +117,19 @@ impl ApObjectHandler for CompositeObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_announce_received(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_announce_of_remote(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_mention(
|
|
||||||
&self,
|
|
||||||
_thought_ap_id: &Url,
|
|
||||||
_mentioned_user_uuid: uuid::Uuid,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,35 +4,51 @@ use domain::ports::EventHandler;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{LocalApContentQuery, UserFederationSettingsQuery},
|
ports::{
|
||||||
value_objects::{MovieId, ReviewId, UserId},
|
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
||||||
|
UserFederationSettingsQuery,
|
||||||
|
},
|
||||||
|
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use k_ap::{ActivityPubService, ApVisibility};
|
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};
|
use crate::urls::{actor_url, goal_url, review_url};
|
||||||
|
|
||||||
pub struct ActivityPubEventHandler {
|
pub struct ActivityPubEventHandler {
|
||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
content_query: Arc<dyn LocalApContentQuery>,
|
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>,
|
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||||
base_url: String,
|
instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityPubEventHandler {
|
impl ActivityPubEventHandler {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
ap_service: Arc<ActivityPubService>,
|
ap_service: Arc<ActivityPubService>,
|
||||||
content_query: Arc<dyn LocalApContentQuery>,
|
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>,
|
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||||
base_url: String,
|
instance: InstanceIdentity,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
ap_service,
|
ap_service,
|
||||||
content_query,
|
content_query,
|
||||||
|
review_repo,
|
||||||
|
movie_repo,
|
||||||
|
goal_repo,
|
||||||
|
stats_repo,
|
||||||
federation_settings,
|
federation_settings,
|
||||||
base_url,
|
instance,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,12 +108,8 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
let inbox: url::Url = inbox_url
|
let inbox: url::Url = inbox_url
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
|
.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
|
self.ap_service
|
||||||
.deliver_to_inbox(inbox, activity, *signing_actor_id)
|
.deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
|
||||||
}
|
}
|
||||||
@@ -127,6 +139,25 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
.on_goal_deleted(user_id, *year)
|
.on_goal_deleted(user_id, *year)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
.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(()),
|
_ => Ok(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,25 +169,21 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.reviews {
|
if !flags.reviews {
|
||||||
return Ok(());
|
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,
|
Some(r) => r,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review_id);
|
let ap_id = review_url(&self.instance, review_id);
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.instance, user_id.value());
|
||||||
|
|
||||||
let movie = self
|
let movie = self
|
||||||
.content_query
|
.movie_repo
|
||||||
.get_movie_by_id(review.movie_id())
|
.get_movie_by_id(review.movie_id())
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -169,24 +196,28 @@ impl ActivityPubEventHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| m.release_year().value())
|
.map(|m| m.release_year().value())
|
||||||
.unwrap_or(0);
|
.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(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
ap_id.clone(),
|
ReviewApInput {
|
||||||
actor,
|
ap_id: ap_id.clone(),
|
||||||
movie_title,
|
actor_url: actor,
|
||||||
release_year,
|
movie_title,
|
||||||
poster_url,
|
release_year,
|
||||||
&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)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let year = review.watched_at().year() as u16;
|
let year = review.watched_at().year() as u16;
|
||||||
@@ -204,25 +235,21 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.reviews {
|
if !flags.reviews {
|
||||||
return Ok(());
|
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,
|
Some(r) => r,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review_id);
|
let ap_id = review_url(&self.instance, review_id);
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.instance, user_id.value());
|
||||||
|
|
||||||
let movie = self
|
let movie = self
|
||||||
.content_query
|
.movie_repo
|
||||||
.get_movie_by_id(review.movie_id())
|
.get_movie_by_id(review.movie_id())
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
@@ -235,24 +262,28 @@ impl ActivityPubEventHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|m| m.release_year().value())
|
.map(|m| m.release_year().value())
|
||||||
.unwrap_or(0);
|
.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(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
ap_id,
|
ReviewApInput {
|
||||||
actor,
|
ap_id,
|
||||||
movie_title,
|
actor_url: actor,
|
||||||
release_year,
|
movie_title,
|
||||||
poster_url,
|
release_year,
|
||||||
&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)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -263,7 +294,7 @@ impl ActivityPubEventHandler {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
review_id: &ReviewId,
|
review_id: &ReviewId,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let ap_id = review_url(&self.base_url, review_id);
|
let ap_id = review_url(&self.instance, review_id);
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -283,28 +314,24 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.watchlist {
|
if !flags.watchlist {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::urls::watchlist_entry_url;
|
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());
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.instance, user_id.value());
|
||||||
|
|
||||||
let poster_url = self
|
let poster_url = self
|
||||||
.content_query
|
.movie_repo
|
||||||
.get_movie_by_id(movie_id)
|
.get_movie_by_id(movie_id)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.and_then(|m| {
|
.and_then(|m| {
|
||||||
m.poster_path()
|
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 =
|
let added_at_utc =
|
||||||
@@ -317,12 +344,12 @@ impl ActivityPubEventHandler {
|
|||||||
external_metadata_id: external_metadata_id.clone(),
|
external_metadata_id: external_metadata_id.clone(),
|
||||||
poster_url,
|
poster_url,
|
||||||
added_at: added_at_utc,
|
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)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -333,7 +360,7 @@ impl ActivityPubEventHandler {
|
|||||||
movie_id: &domain::value_objects::MovieId,
|
movie_id: &domain::value_objects::MovieId,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
use crate::urls::watchlist_entry_url;
|
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
|
self.ap_service
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -346,14 +373,17 @@ impl ActivityPubEventHandler {
|
|||||||
.get_local_reviews_for_movie(movie_id)
|
.get_local_reviews_for_movie(movie_id)
|
||||||
.await?;
|
.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 {
|
let movie = match movie {
|
||||||
Some(m) => m,
|
Some(m) => m,
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
};
|
};
|
||||||
|
let external_metadata_id = movie
|
||||||
|
.external_metadata_id()
|
||||||
|
.map(|id| id.value().to_string());
|
||||||
let poster_url = movie
|
let poster_url = movie
|
||||||
.poster_path()
|
.poster_path()
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
|
.map(|p| self.instance.image_url_for(p.value()));
|
||||||
|
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let review = entry.review();
|
let review = entry.review();
|
||||||
@@ -363,31 +393,30 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.reviews {
|
if !flags.reviews {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ap_id = review_url(&self.base_url, review.id());
|
let ap_id = review_url(&self.instance, review.id());
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.instance, user_id.value());
|
||||||
|
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
review,
|
review,
|
||||||
ap_id,
|
ReviewApInput {
|
||||||
actor,
|
ap_id,
|
||||||
movie.title().value().to_string(),
|
actor_url: actor,
|
||||||
movie.release_year().value(),
|
movie_title: movie.title().value().to_string(),
|
||||||
poster_url.clone(),
|
release_year: movie.release_year().value(),
|
||||||
&self.base_url,
|
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)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
|
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,36 +432,37 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.goals {
|
if !flags.goals {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let Some((goal, current)) = self
|
let Some(goal) = self
|
||||||
.content_query
|
.goal_repo
|
||||||
.get_goal_with_progress(user_id, year)
|
.find_by_user_and_year(user_id, year)
|
||||||
.await
|
.await
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
else {
|
else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
let current = self
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
.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(
|
let obj = goal_to_ap_object(
|
||||||
ap_id,
|
ap_id,
|
||||||
actor,
|
actor,
|
||||||
year,
|
year,
|
||||||
goal.target_count(),
|
goal.target_count(),
|
||||||
current,
|
current,
|
||||||
&self.base_url,
|
self.instance.base_url(),
|
||||||
);
|
);
|
||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -448,34 +478,34 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.goals {
|
if !flags.goals {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let current = self
|
let current = self
|
||||||
.content_query
|
.stats_repo
|
||||||
.get_goal_with_progress(user_id, year)
|
.count_reviews_in_year(user_id, year)
|
||||||
.await
|
.await
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|(_, c)| c)
|
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let ap_id = goal_url(&self.base_url, user_id.value(), year);
|
let ap_id = goal_url(&self.instance, user_id.value(), year);
|
||||||
let actor = actor_url(&self.base_url, user_id.value());
|
let actor = actor_url(&self.instance, user_id.value());
|
||||||
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
|
let obj = goal_to_ap_object(
|
||||||
|
ap_id,
|
||||||
|
actor,
|
||||||
|
year,
|
||||||
|
target_count,
|
||||||
|
current,
|
||||||
|
self.instance.base_url(),
|
||||||
|
);
|
||||||
let json = serde_json::to_value(obj)?;
|
let json = serde_json::to_value(obj)?;
|
||||||
if is_create {
|
if is_create {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
} else {
|
} else {
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
|
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -486,15 +516,11 @@ impl ActivityPubEventHandler {
|
|||||||
.federation_settings
|
.federation_settings
|
||||||
.get_federation_flags(user_id)
|
.get_federation_flags(user_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(domain::models::FederationFlags {
|
.unwrap_or_default();
|
||||||
goals: true,
|
|
||||||
reviews: true,
|
|
||||||
watchlist: true,
|
|
||||||
});
|
|
||||||
if !flags.goals {
|
if !flags.goals {
|
||||||
return Ok(());
|
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
|
self.ap_service
|
||||||
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
.broadcast_delete_to_followers(user_id.value(), ap_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -33,22 +33,41 @@ impl k_ap::EventPublisher for FederationEventBridge {
|
|||||||
inbox,
|
inbox,
|
||||||
activity,
|
activity,
|
||||||
signing_actor_id,
|
signing_actor_id,
|
||||||
} => {
|
} => self
|
||||||
let json = serde_json::to_string(&activity)
|
.domain_publisher
|
||||||
.map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?;
|
.publish(&DomainEvent::FederationDeliveryRequested {
|
||||||
self.domain_publisher
|
inbox_url: inbox.to_string(),
|
||||||
.publish(&DomainEvent::FederationDeliveryRequested {
|
activity_json: activity,
|
||||||
inbox_url: inbox.to_string(),
|
signing_actor_id,
|
||||||
activity_json: json,
|
})
|
||||||
signing_actor_id,
|
.await
|
||||||
})
|
.map_err(|e| anyhow::anyhow!(e.to_string())),
|
||||||
.await
|
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
|
||||||
}
|
|
||||||
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
FederationEvent::DeliveryFailed { inbox, error, .. } => {
|
||||||
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
|
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
|
||||||
Ok(())
|
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 std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{models::RemoteGoalEntry, ports::RemoteGoalRepository};
|
use chrono::DateTime;
|
||||||
use k_ap::ApObjectHandler;
|
use domain::{
|
||||||
|
models::RemoteGoalEntry,
|
||||||
|
ports::{GoalQuery, RemoteGoalRepository},
|
||||||
|
value_objects::{InstanceIdentity, UserId},
|
||||||
|
};
|
||||||
|
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use url::Url;
|
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 struct GoalObjectHandler {
|
||||||
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
|
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]
|
#[async_trait]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
pub mod composite_handler;
|
pub mod composite_handler;
|
||||||
pub mod event_handler;
|
pub mod event_handler;
|
||||||
pub mod federation_event_bridge;
|
pub mod federation_event_bridge;
|
||||||
|
pub mod federation_ports;
|
||||||
pub mod goal_handler;
|
pub mod goal_handler;
|
||||||
pub mod objects;
|
pub mod objects;
|
||||||
pub mod port;
|
|
||||||
pub mod remote_review_repository;
|
pub mod remote_review_repository;
|
||||||
pub mod review_handler;
|
pub mod review_handler;
|
||||||
|
pub mod social_adapter;
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
pub mod user_adapter;
|
pub mod user_adapter;
|
||||||
pub mod watchlist_handler;
|
pub mod watchlist_handler;
|
||||||
@@ -17,27 +18,39 @@ pub const INSTANCE_ACTOR_ID: uuid::Uuid =
|
|||||||
pub use k_ap::{
|
pub use k_ap::{
|
||||||
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
|
||||||
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
|
||||||
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
|
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use event_handler::ActivityPubEventHandler;
|
pub use event_handler::ActivityPubEventHandler;
|
||||||
pub use port::{ActivityPubPort, NoopActivityPubService};
|
pub use federation_ports::ApServiceAdapter;
|
||||||
pub use remote_review_repository::RemoteReviewRepository;
|
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
pub use review_handler::ReviewObjectHandler;
|
pub use review_handler::ReviewObjectHandler;
|
||||||
|
pub use social_adapter::CompositeSocialAdapter;
|
||||||
pub use user_adapter::DomainUserRepoAdapter;
|
pub use user_adapter::DomainUserRepoAdapter;
|
||||||
|
|
||||||
pub type FederationRepos = (
|
pub struct FederationRepos {
|
||||||
std::sync::Arc<dyn ActivityRepository>,
|
pub activity: std::sync::Arc<dyn ActivityRepository>,
|
||||||
std::sync::Arc<dyn FollowRepository>,
|
pub follow: std::sync::Arc<dyn FollowRepository>,
|
||||||
std::sync::Arc<dyn ActorRepository>,
|
pub actor: std::sync::Arc<dyn ActorRepository>,
|
||||||
std::sync::Arc<dyn BlocklistRepository>,
|
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
|
||||||
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
|
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
|
||||||
std::sync::Arc<dyn RemoteReviewRepository>,
|
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||||
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
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 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 router: axum::Router,
|
||||||
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
|
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_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||||
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
||||||
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
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 user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
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 allow_registration: bool,
|
||||||
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
|
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_watchlist_repo,
|
||||||
remote_goal_repo,
|
remote_goal_repo,
|
||||||
local_ap_content,
|
local_ap_content,
|
||||||
|
movie_repo,
|
||||||
|
review_repo,
|
||||||
|
diary_repo,
|
||||||
|
goal_repo,
|
||||||
|
stats_repo,
|
||||||
user_repo,
|
user_repo,
|
||||||
federation_settings,
|
federation_settings,
|
||||||
base_url,
|
follow_command: _,
|
||||||
|
follow_query: _,
|
||||||
|
instance,
|
||||||
allow_registration,
|
allow_registration,
|
||||||
event_publisher,
|
event_publisher,
|
||||||
} = deps;
|
} = deps;
|
||||||
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
|
||||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
content_query: std::sync::Arc::clone(&local_ap_content),
|
||||||
|
movie_repo: std::sync::Arc::clone(&movie_repo),
|
||||||
|
diary_repo,
|
||||||
review_store,
|
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 {
|
let watchlist_handler = std::sync::Arc::new(watchlist_handler::WatchlistObjectHandler {
|
||||||
remote_watchlist_repo,
|
remote_watchlist_repo,
|
||||||
content_query: std::sync::Arc::clone(&local_ap_content),
|
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 {
|
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||||
review: review_handler,
|
review: review_handler,
|
||||||
watchlist: watchlist_handler,
|
watchlist: watchlist_handler,
|
||||||
@@ -107,14 +141,14 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let concrete = std::sync::Arc::new(
|
let concrete = std::sync::Arc::new(
|
||||||
ActivityPubService::builder(base_url.clone())
|
ActivityPubService::builder(instance.base_url().to_string())
|
||||||
.activity_repo(activity_repo)
|
.activity_repo(activity_repo)
|
||||||
.follow_repo(follow_repo)
|
.follow_repo(follow_repo)
|
||||||
.actor_repo(actor_repo)
|
.actor_repo(actor_repo)
|
||||||
.blocklist_repo(blocklist_repo)
|
.blocklist_repo(blocklist_repo)
|
||||||
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
|
||||||
user_repo,
|
user_repo,
|
||||||
base_url.clone(),
|
instance.clone(),
|
||||||
)))
|
)))
|
||||||
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
|
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
|
||||||
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
|
.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)
|
.event_publisher(fed_event_bridge)
|
||||||
.allow_registration(allow_registration)
|
.allow_registration(allow_registration)
|
||||||
.software_name("movies-diary")
|
.software_name("movies-diary")
|
||||||
|
.nodeinfo_metadata(serde_json::json!({
|
||||||
|
"nodeName": "movies-diary",
|
||||||
|
"nodeDescription": "A federated movie diary"
|
||||||
|
}))
|
||||||
.debug(federation_debug)
|
.debug(federation_debug)
|
||||||
.build()
|
.build()
|
||||||
.await?,
|
.await?,
|
||||||
@@ -131,12 +169,25 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
|
||||||
std::sync::Arc::clone(&concrete),
|
std::sync::Arc::clone(&concrete),
|
||||||
local_ap_content,
|
local_ap_content,
|
||||||
|
review_repo,
|
||||||
|
movie_repo,
|
||||||
|
goal_repo,
|
||||||
|
stats_repo,
|
||||||
federation_settings,
|
federation_settings,
|
||||||
base_url,
|
instance,
|
||||||
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
|
)) 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 {
|
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,
|
router,
|
||||||
event_handler,
|
event_handler,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use k_ap::AS_PUBLIC;
|
use k_ap::AS_PUBLIC;
|
||||||
use k_ap::NoteType;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use domain::models::Review;
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct ApHashtag {
|
pub struct ApHashtag {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -22,7 +40,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ReviewObject {
|
pub struct ReviewObject {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub(crate) kind: NoteType,
|
pub(crate) kind: ActivityStreamsType,
|
||||||
pub(crate) id: Url,
|
pub(crate) id: Url,
|
||||||
pub(crate) attributed_to: Url,
|
pub(crate) attributed_to: Url,
|
||||||
pub(crate) content: String,
|
pub(crate) content: String,
|
||||||
@@ -31,10 +49,19 @@ pub struct ReviewObject {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) release_year: u16,
|
pub(crate) release_year: u16,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub(crate) external_metadata_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub(crate) poster_url: Option<String>,
|
pub(crate) poster_url: Option<String>,
|
||||||
pub(crate) rating: u8,
|
pub(crate) rating: u8,
|
||||||
pub(crate) comment: Option<String>,
|
pub(crate) comment: Option<String>,
|
||||||
pub(crate) watched_at: DateTime<Utc>,
|
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)]
|
#[serde(default)]
|
||||||
pub(crate) tag: Vec<ApHashtag>,
|
pub(crate) tag: Vec<ApHashtag>,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||||
@@ -43,17 +70,26 @@ pub struct ReviewObject {
|
|||||||
pub(crate) cc: Vec<String>,
|
pub(crate) cc: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Serialize a local Review into a ReviewObject for AP delivery.
|
pub struct ReviewApInput {
|
||||||
/// Takes movie metadata explicitly since the handler fetches it separately.
|
pub ap_id: Url,
|
||||||
pub fn review_to_ap_object(
|
pub actor_url: Url,
|
||||||
review: &Review,
|
pub movie_title: String,
|
||||||
ap_id: Url,
|
pub release_year: u16,
|
||||||
actor_url: Url,
|
pub external_metadata_id: Option<String>,
|
||||||
movie_title: String,
|
pub poster_url: Option<String>,
|
||||||
release_year: u16,
|
pub base_url: String,
|
||||||
poster_url: Option<String>,
|
}
|
||||||
base_url: &str,
|
|
||||||
) -> ReviewObject {
|
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 stars: String = "\u{2B50}".repeat(review.rating().value() as usize);
|
||||||
let comment_text = review.comment().map(|c| c.value().to_string());
|
let comment_text = review.comment().map(|c| c.value().to_string());
|
||||||
let year_str = if release_year > 0 {
|
let year_str = if release_year > 0 {
|
||||||
@@ -84,19 +120,32 @@ pub fn review_to_ap_object(
|
|||||||
name: format!("#{}", normalized),
|
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 {
|
ReviewObject {
|
||||||
kind: NoteType::default(),
|
kind: ActivityStreamsType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url.clone(),
|
attributed_to: actor_url.clone(),
|
||||||
content,
|
content,
|
||||||
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
||||||
movie_title,
|
movie_title,
|
||||||
release_year,
|
release_year,
|
||||||
|
external_metadata_id,
|
||||||
poster_url,
|
poster_url,
|
||||||
rating: review.rating().value(),
|
rating: review.rating().value(),
|
||||||
comment: comment_text,
|
comment: comment_text,
|
||||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
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,
|
tag,
|
||||||
to: vec![AS_PUBLIC.to_string()],
|
to: vec![AS_PUBLIC.to_string()],
|
||||||
cc: vec![format!("{}/followers", actor_url)],
|
cc: vec![format!("{}/followers", actor_url)],
|
||||||
@@ -107,7 +156,7 @@ pub fn review_to_ap_object(
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct WatchlistObject {
|
pub struct WatchlistObject {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub(crate) kind: NoteType,
|
pub(crate) kind: ActivityStreamsType,
|
||||||
pub(crate) id: Url,
|
pub(crate) id: Url,
|
||||||
pub(crate) attributed_to: Url,
|
pub(crate) attributed_to: Url,
|
||||||
pub(crate) content: String,
|
pub(crate) content: String,
|
||||||
@@ -175,7 +224,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
|||||||
];
|
];
|
||||||
|
|
||||||
WatchlistObject {
|
WatchlistObject {
|
||||||
kind: NoteType::default(),
|
kind: ActivityStreamsType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url.clone(),
|
attributed_to: actor_url.clone(),
|
||||||
content,
|
content,
|
||||||
@@ -197,7 +246,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GoalObject {
|
pub struct GoalObject {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
pub(crate) kind: NoteType,
|
pub(crate) kind: ActivityStreamsType,
|
||||||
pub(crate) id: Url,
|
pub(crate) id: Url,
|
||||||
pub(crate) attributed_to: Url,
|
pub(crate) attributed_to: Url,
|
||||||
pub(crate) content: String,
|
pub(crate) content: String,
|
||||||
@@ -234,7 +283,7 @@ pub fn goal_to_ap_object(
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
GoalObject {
|
GoalObject {
|
||||||
kind: NoteType::default(),
|
kind: ActivityStreamsType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url.clone(),
|
attributed_to: actor_url.clone(),
|
||||||
content,
|
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 chrono::NaiveDateTime;
|
||||||
use domain::models::Review;
|
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]
|
#[async_trait]
|
||||||
pub trait RemoteReviewRepository: Send + Sync {
|
pub trait RemoteReviewRepository: Send + Sync {
|
||||||
async fn save_remote_review(
|
async fn save_remote_review(
|
||||||
@@ -11,20 +21,13 @@ pub trait RemoteReviewRepository: Send + Sync {
|
|||||||
ap_id: &str,
|
ap_id: &str,
|
||||||
movie_title: &str,
|
movie_title: &str,
|
||||||
release_year: u16,
|
release_year: u16,
|
||||||
|
external_metadata_id: Option<&str>,
|
||||||
poster_url: Option<&str>,
|
poster_url: Option<&str>,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
|
|
||||||
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
|
||||||
|
|
||||||
async fn update_remote_review(
|
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
|
||||||
&self,
|
|
||||||
ap_id: &str,
|
|
||||||
actor_url: &str,
|
|
||||||
rating: u8,
|
|
||||||
comment: Option<&str>,
|
|
||||||
watched_at: NaiveDateTime,
|
|
||||||
poster_url: Option<&str>,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
async fn delete_by_actor(&self, actor_url: &str) -> 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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
|
events::DomainEvent,
|
||||||
models::ReviewSource,
|
models::ReviewSource,
|
||||||
ports::LocalApContentQuery,
|
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||||
value_objects::{Comment, MovieId, Rating, ReviewId, UserId},
|
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 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::remote_review_repository::RemoteReviewRepository;
|
||||||
use crate::urls::{actor_url, review_url};
|
use crate::urls::{actor_url, review_url};
|
||||||
|
|
||||||
pub struct ReviewObjectHandler {
|
pub struct ReviewObjectHandler {
|
||||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
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 review_store: Arc<dyn RemoteReviewRepository>,
|
||||||
pub base_url: String,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
pub instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -26,7 +32,7 @@ impl ApContentReader for ReviewObjectHandler {
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
before: Option<chrono::DateTime<chrono::Utc>>,
|
before: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
limit: usize,
|
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 domain_user_id = UserId::from_uuid(user_id);
|
||||||
let before_naive = before.map(|dt| dt.naive_utc());
|
let before_naive = before.map(|dt| dt.naive_utc());
|
||||||
let entries = self
|
let entries = self
|
||||||
@@ -35,34 +41,48 @@ impl ApContentReader for ReviewObjectHandler {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
.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();
|
let mut results = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let review = entry.review();
|
let review = entry.review();
|
||||||
let published =
|
let published =
|
||||||
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
|
||||||
let movie = entry.movie();
|
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
|
let poster_url = movie
|
||||||
.poster_path()
|
.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(
|
let obj = review_to_ap_object(
|
||||||
review,
|
review,
|
||||||
ap_id.clone(),
|
ReviewApInput {
|
||||||
actor.clone(),
|
ap_id: ap_id.clone(),
|
||||||
movie.title().value().to_string(),
|
actor_url: actor.clone(),
|
||||||
movie.release_year().value(),
|
movie_title: movie.title().value().to_string(),
|
||||||
poster_url,
|
release_year: movie.release_year().value(),
|
||||||
&self.base_url,
|
external_metadata_id: movie
|
||||||
|
.external_metadata_id()
|
||||||
|
.map(|id| id.value().to_string()),
|
||||||
|
poster_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)
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
async fn count_local_posts(&self) -> anyhow::Result<u64> {
|
||||||
self.content_query
|
self.diary_repo
|
||||||
.count_local_posts()
|
.count_local_posts()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!(e.to_string()))
|
.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 actor_url_str = obj.attributed_to.to_string();
|
||||||
let review_id = ReviewId::generate();
|
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 {
|
||||||
&uuid::Uuid::NAMESPACE_URL,
|
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
|
||||||
obj.movie_title.as_bytes(),
|
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,
|
||||||
|
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(
|
let user_id = UserId::from_uuid(uuid::Uuid::new_v5(
|
||||||
&uuid::Uuid::NAMESPACE_URL,
|
&uuid::Uuid::NAMESPACE_URL,
|
||||||
actor_url_str.as_bytes(),
|
actor_url_str.as_bytes(),
|
||||||
));
|
));
|
||||||
let rating = Rating::new(obj.rating.min(5))?;
|
let rating = Rating::new(obj.rating.min(5))?;
|
||||||
let comment = obj.comment.map(Comment::new).transpose()?;
|
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 {
|
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
|
||||||
id: review_id,
|
id: review_id,
|
||||||
movie_id,
|
movie_id: movie_id.clone(),
|
||||||
user_id,
|
user_id,
|
||||||
rating,
|
rating,
|
||||||
comment,
|
comment,
|
||||||
@@ -111,6 +156,7 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
source: ReviewSource::Remote {
|
source: ReviewSource::Remote {
|
||||||
actor_url: actor_url_str,
|
actor_url: actor_url_str,
|
||||||
},
|
},
|
||||||
|
watch_medium,
|
||||||
});
|
});
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
@@ -119,10 +165,23 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
obj.id.as_str(),
|
obj.id.as_str(),
|
||||||
&obj.movie_title,
|
&obj.movie_title,
|
||||||
obj.release_year,
|
obj.release_year,
|
||||||
|
obj.external_metadata_id.as_deref(),
|
||||||
obj.poster_url.as_deref(),
|
obj.poster_url.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,14 +206,15 @@ impl ApObjectHandler for ReviewObjectHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.review_store
|
self.review_store
|
||||||
.update_remote_review(
|
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
|
||||||
ap_id.as_str(),
|
ap_id: ap_id.as_str(),
|
||||||
actor_url.as_str(),
|
actor_url: actor_url.as_str(),
|
||||||
obj.rating.min(5),
|
rating: obj.rating.min(5),
|
||||||
obj.comment.as_deref(),
|
comment: obj.comment.as_deref(),
|
||||||
obj.watched_at.naive_utc(),
|
watched_at: obj.watched_at.naive_utc(),
|
||||||
obj.poster_url.as_deref(),
|
poster_url: obj.poster_url.as_deref(),
|
||||||
)
|
watch_medium: obj.watch_medium.as_deref(),
|
||||||
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
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")
|
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
source: ReviewSource::Local,
|
source: ReviewSource::Local,
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
"https://example.com/reviews/1".parse().unwrap(),
|
ReviewApInput {
|
||||||
"https://example.com/users/1".parse().unwrap(),
|
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||||
"Dune".to_string(),
|
actor_url: "https://example.com/users/1".parse().unwrap(),
|
||||||
2021,
|
movie_title: "Dune".to_string(),
|
||||||
None,
|
release_year: 2021,
|
||||||
"https://example.com",
|
external_metadata_id: None,
|
||||||
|
poster_url: None,
|
||||||
|
base_url: "https://example.com".to_string(),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
assert_eq!(obj.tag.len(), 2);
|
assert_eq!(obj.tag.len(), 2);
|
||||||
let names: Vec<&str> = obj.tag.iter().map(|t| t.name.as_str()).collect();
|
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")
|
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
source: ReviewSource::Local,
|
source: ReviewSource::Local,
|
||||||
|
watch_medium: None,
|
||||||
});
|
});
|
||||||
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
|
||||||
let obj = review_to_ap_object(
|
let obj = review_to_ap_object(
|
||||||
&review,
|
&review,
|
||||||
"https://example.com/reviews/1".parse().unwrap(),
|
ReviewApInput {
|
||||||
actor_url.clone(),
|
ap_id: "https://example.com/reviews/1".parse().unwrap(),
|
||||||
"Dune".to_string(),
|
actor_url: actor_url.clone(),
|
||||||
2021,
|
movie_title: "Dune".to_string(),
|
||||||
None,
|
release_year: 2021,
|
||||||
"https://example.com",
|
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.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
|
||||||
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
|
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;
|
use url::Url;
|
||||||
|
|
||||||
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
|
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
|
||||||
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
|
pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url {
|
||||||
Url::parse(&format!("{}/users/{}", base_url, user_id))
|
Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id)))
|
||||||
.expect("base_url is always a valid URL prefix")
|
.expect("base_url is always a valid URL prefix")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
|
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
|
||||||
pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
|
pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url {
|
||||||
Url::parse(&format!("{}/reviews/{}", 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))
|
|
||||||
.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 {
|
|
||||||
Url::parse(&format!(
|
Url::parse(&format!(
|
||||||
"{}/users/{}/watchlist/{}",
|
"{}/reviews/{}",
|
||||||
base_url, user_id, movie_id
|
instance.base_url(),
|
||||||
|
review_id.value()
|
||||||
|
))
|
||||||
|
.expect("base_url is always a valid URL prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
instance: &InstanceIdentity,
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
movie_id: uuid::Uuid,
|
||||||
|
) -> Url {
|
||||||
|
Url::parse(&format!(
|
||||||
|
"{}/users/{}/watchlist/{}",
|
||||||
|
instance.base_url(),
|
||||||
|
user_id,
|
||||||
|
movie_id
|
||||||
))
|
))
|
||||||
.expect("base_url is always a valid URL prefix")
|
.expect("base_url is always a valid URL prefix")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 k_ap::{ApProfileField, ApUser, ApUserRepository};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub struct DomainUserRepoAdapter {
|
pub struct DomainUserRepoAdapter {
|
||||||
pub repo: Arc<dyn UserRepository>,
|
pub repo: Arc<dyn UserRepository>,
|
||||||
pub base_url: String,
|
pub instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DomainUserRepoAdapter {
|
impl DomainUserRepoAdapter {
|
||||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
pub fn new(repo: Arc<dyn UserRepository>, instance: InstanceIdentity) -> Self {
|
||||||
Self { repo, base_url }
|
Self { repo, instance }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
fn build_user(&self, u: &domain::models::User) -> ApUser {
|
||||||
let avatar_url = u
|
let avatar_url = u
|
||||||
.avatar_path()
|
.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
|
let banner_url = u
|
||||||
.banner_path()
|
.banner_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 profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
let profile_url = Url::parse(&format!(
|
||||||
|
"{}/u/{}",
|
||||||
|
self.instance.base_url(),
|
||||||
|
u.username().value()
|
||||||
|
))
|
||||||
|
.ok();
|
||||||
ApUser {
|
ApUser {
|
||||||
id: u.id().value(),
|
id: u.id().value(),
|
||||||
username: u.username().value().to_string(),
|
username: u.username().value().to_string(),
|
||||||
@@ -46,12 +54,8 @@ impl DomainUserRepoAdapter {
|
|||||||
manually_approves_followers: true,
|
manually_approves_followers: true,
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
actor_type: Default::default(),
|
actor_type: Default::default(),
|
||||||
featured_url: Url::parse(&format!(
|
featured_url: Url::parse(&format!("{}/featured", self.instance.actor_url_for(u.id())))
|
||||||
"{}/users/{}/featured",
|
.ok(),
|
||||||
self.base_url,
|
|
||||||
u.id().value()
|
|
||||||
))
|
|
||||||
.ok(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,76 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use chrono::DateTime;
|
||||||
use domain::{
|
use domain::{
|
||||||
models::RemoteWatchlistEntry,
|
models::{RemoteWatchlistEntry, WatchlistWithMovie},
|
||||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
||||||
|
value_objects::{InstanceIdentity, UserId},
|
||||||
};
|
};
|
||||||
use k_ap::ApObjectHandler;
|
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use url::Url;
|
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 struct WatchlistObjectHandler {
|
||||||
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
|
||||||
pub content_query: Arc<dyn LocalApContentQuery>,
|
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]
|
#[async_trait]
|
||||||
@@ -49,10 +106,32 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
|||||||
|
|
||||||
async fn on_update(
|
async fn on_update(
|
||||||
&self,
|
&self,
|
||||||
_ap_id: &Url,
|
ap_id: &Url,
|
||||||
_actor_url: &Url,
|
actor_url: &Url,
|
||||||
_object: serde_json::Value,
|
object: serde_json::Value,
|
||||||
) -> anyhow::Result<()> {
|
) -> 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,36 +150,19 @@ impl ApObjectHandler for WatchlistObjectHandler {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn on_like(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
async fn on_like(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_announce_received(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_announce_received(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_announce_of_remote(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_announce_of_remote(
|
|
||||||
&self,
|
|
||||||
_object_url: &Url,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_unlike(&self, _: &Url, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_unlike(&self, _object_url: &Url, _actor_url: &Url) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
async fn on_mention(&self, _: &Url, _: uuid::Uuid, _: &Url) -> anyhow::Result<()> {
|
||||||
async fn on_mention(
|
|
||||||
&self,
|
|
||||||
_thought_ap_id: &Url,
|
|
||||||
_mentioned_user_uuid: uuid::Uuid,
|
|
||||||
_actor_url: &Url,
|
|
||||||
) -> anyhow::Result<()> {
|
|
||||||
Ok(())
|
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,
|
events::DomainEvent,
|
||||||
models::{ExternalPersonId, PersonId},
|
models::{ExternalPersonId, PersonId},
|
||||||
value_objects::{
|
value_objects::{
|
||||||
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
|
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId,
|
||||||
|
WrapUpId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -61,10 +62,40 @@ pub enum EventPayload {
|
|||||||
user_id: String,
|
user_id: String,
|
||||||
movie_id: String,
|
movie_id: String,
|
||||||
},
|
},
|
||||||
|
FollowRequested {
|
||||||
|
follower_id: String,
|
||||||
|
target_kind: String,
|
||||||
|
target_id: String,
|
||||||
|
},
|
||||||
FollowAccepted {
|
FollowAccepted {
|
||||||
local_user_id: String,
|
owner_id: String,
|
||||||
remote_actor_url: String,
|
requester_kind: String,
|
||||||
outbox_url: 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 {
|
BackfillFollower {
|
||||||
owner_user_id: String,
|
owner_user_id: String,
|
||||||
@@ -72,7 +103,7 @@ pub enum EventPayload {
|
|||||||
},
|
},
|
||||||
FederationDeliveryRequested {
|
FederationDeliveryRequested {
|
||||||
inbox_url: String,
|
inbox_url: String,
|
||||||
activity_json: String,
|
activity_json: serde_json::Value,
|
||||||
signing_actor_id: String,
|
signing_actor_id: String,
|
||||||
},
|
},
|
||||||
WatchEventIngested {
|
WatchEventIngested {
|
||||||
@@ -114,6 +145,13 @@ pub enum EventPayload {
|
|||||||
person_id: String,
|
person_id: String,
|
||||||
external_person_id: String,
|
external_person_id: String,
|
||||||
},
|
},
|
||||||
|
UserDeleted {
|
||||||
|
user_id: String,
|
||||||
|
},
|
||||||
|
UserAccountMoved {
|
||||||
|
user_id: String,
|
||||||
|
new_actor_url: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EventPayload {
|
impl EventPayload {
|
||||||
@@ -129,7 +167,13 @@ impl EventPayload {
|
|||||||
EventPayload::ImageStored { .. } => "ImageStored",
|
EventPayload::ImageStored { .. } => "ImageStored",
|
||||||
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
|
||||||
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
|
||||||
|
EventPayload::FollowRequested { .. } => "FollowRequested",
|
||||||
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
EventPayload::FollowAccepted { .. } => "FollowAccepted",
|
||||||
|
EventPayload::FollowRejected { .. } => "FollowRejected",
|
||||||
|
EventPayload::Unfollowed { .. } => "Unfollowed",
|
||||||
|
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
|
||||||
|
EventPayload::ActorBlocked { .. } => "ActorBlocked",
|
||||||
|
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
|
||||||
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
EventPayload::BackfillFollower { .. } => "BackfillFollower",
|
||||||
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
|
||||||
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
|
||||||
@@ -141,6 +185,8 @@ impl EventPayload {
|
|||||||
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
EventPayload::GoalUpdated { .. } => "GoalUpdated",
|
||||||
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
EventPayload::GoalDeleted { .. } => "GoalDeleted",
|
||||||
EventPayload::PersonEnrichmentRequested { .. } => "PersonEnrichmentRequested",
|
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}")))
|
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> {
|
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
|
||||||
chrono::DateTime::from_timestamp(ts, 0)
|
chrono::DateTime::from_timestamp(ts, 0)
|
||||||
.map(|dt| dt.naive_utc())
|
.map(|dt| dt.naive_utc())
|
||||||
@@ -234,15 +318,62 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
movie_id: movie_id.value().to_string(),
|
movie_id: movie_id.value().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DomainEvent::FollowAccepted {
|
DomainEvent::FollowRequested { follower, target } => {
|
||||||
local_user_id,
|
let (kind, id) = follow_target_to_payload(target);
|
||||||
remote_actor_url,
|
EventPayload::FollowRequested {
|
||||||
outbox_url,
|
follower_id: follower.value().to_string(),
|
||||||
} => EventPayload::FollowAccepted {
|
target_kind: kind,
|
||||||
local_user_id: local_user_id.value().to_string(),
|
target_id: id,
|
||||||
remote_actor_url: remote_actor_url.clone(),
|
}
|
||||||
outbox_url: outbox_url.clone(),
|
}
|
||||||
},
|
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 {
|
DomainEvent::BackfillFollower {
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
follower_inbox_url,
|
follower_inbox_url,
|
||||||
@@ -324,6 +455,16 @@ impl From<&DomainEvent> for EventPayload {
|
|||||||
person_id: person_id.value().to_string(),
|
person_id: person_id.value().to_string(),
|
||||||
external_person_id: external_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")?),
|
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 {
|
EventPayload::FollowAccepted {
|
||||||
local_user_id,
|
owner_id,
|
||||||
remote_actor_url,
|
requester_kind,
|
||||||
outbox_url,
|
requester_id,
|
||||||
} => Ok(DomainEvent::FollowAccepted {
|
} => Ok(DomainEvent::FollowAccepted {
|
||||||
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
|
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
|
||||||
remote_actor_url,
|
requester: payload_to_identity(&requester_kind, requester_id)?,
|
||||||
outbox_url,
|
}),
|
||||||
|
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 {
|
EventPayload::BackfillFollower {
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -517,6 +705,16 @@ impl TryFrom<EventPayload> for DomainEvent {
|
|||||||
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
|
||||||
external_person_id: ExternalPersonId::new(external_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()
|
.unwrap()
|
||||||
.and_hms_opt(0, 0, 0)
|
.and_hms_opt(0, 0, 0)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
DiaryEntry::new(movie, review)
|
DiaryEntry::new(movie, review)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use domain::{
|
|||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{MetadataSearchCriteria, Movie},
|
models::{MetadataSearchCriteria, Movie},
|
||||||
ports::MetadataClient,
|
ports::MetadataClient,
|
||||||
value_objects::{ExternalMetadataId, MovieTitle, PosterUrl, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, PosterUrl, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
mod omdb;
|
mod omdb;
|
||||||
@@ -47,7 +47,9 @@ impl MetadataClient for MetadataClientImpl {
|
|||||||
criteria: &MetadataSearchCriteria,
|
criteria: &MetadataSearchCriteria,
|
||||||
) -> Result<Movie, DomainError> {
|
) -> Result<Movie, DomainError> {
|
||||||
let pm = self.provider.fetch(criteria).await?;
|
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),
|
Some(pm.imdb_id),
|
||||||
pm.title,
|
pm.title,
|
||||||
pm.release_year,
|
pm.release_year,
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
|||||||
DomainEvent::ImageStored { .. } => "image.stored",
|
DomainEvent::ImageStored { .. } => "image.stored",
|
||||||
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
|
||||||
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
|
||||||
|
DomainEvent::FollowRequested { .. } => "follow.requested",
|
||||||
DomainEvent::FollowAccepted { .. } => "follow.accepted",
|
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::BackfillFollower { .. } => "backfill.follower",
|
||||||
DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested",
|
||||||
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
|
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",
|
||||||
@@ -24,6 +30,8 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
|
|||||||
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
DomainEvent::GoalUpdated { .. } => "goal.updated",
|
||||||
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
DomainEvent::GoalDeleted { .. } => "goal.deleted",
|
||||||
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
|
DomainEvent::PersonEnrichmentRequested { .. } => "person.enrichment.requested",
|
||||||
|
DomainEvent::UserDeleted { .. } => "user.deleted",
|
||||||
|
DomainEvent::UserAccountMoved { .. } => "user.account.moved",
|
||||||
};
|
};
|
||||||
format!("{prefix}.{suffix}")
|
format!("{prefix}.{suffix}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
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 {
|
pub struct ReqwestPosterFetcher {
|
||||||
client: reqwest::Client,
|
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>> {
|
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
|
||||||
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
|
||||||
PosterFetcherConfig::from_env(),
|
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,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
ports::{
|
ports::{
|
||||||
EventHandler, EventPublisher, MetadataClient, MovieRepository, ObjectStorage,
|
EventHandler, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage,
|
||||||
PosterFetcherClient,
|
PosterFetcherClient,
|
||||||
},
|
},
|
||||||
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
value_objects::{ExternalMetadataId, MovieId, PosterPath},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct PosterSyncHandler {
|
pub struct PosterSyncHandler {
|
||||||
movie_repository: Arc<dyn MovieRepository>,
|
movie_command: Arc<dyn MovieCommand>,
|
||||||
|
movie_query: Arc<dyn MovieQuery>,
|
||||||
metadata_client: Arc<dyn MetadataClient>,
|
metadata_client: Arc<dyn MetadataClient>,
|
||||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
object_storage: Arc<dyn ObjectStorage>,
|
object_storage: Arc<dyn ObjectStorage>,
|
||||||
@@ -22,7 +23,8 @@ pub struct PosterSyncHandler {
|
|||||||
|
|
||||||
impl PosterSyncHandler {
|
impl PosterSyncHandler {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
movie_repository: Arc<dyn MovieRepository>,
|
movie_command: Arc<dyn MovieCommand>,
|
||||||
|
movie_query: Arc<dyn MovieQuery>,
|
||||||
metadata_client: Arc<dyn MetadataClient>,
|
metadata_client: Arc<dyn MetadataClient>,
|
||||||
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
poster_fetcher: Arc<dyn PosterFetcherClient>,
|
||||||
object_storage: Arc<dyn ObjectStorage>,
|
object_storage: Arc<dyn ObjectStorage>,
|
||||||
@@ -30,7 +32,8 @@ impl PosterSyncHandler {
|
|||||||
max_retries: u32,
|
max_retries: u32,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
movie_repository,
|
movie_command,
|
||||||
|
movie_query,
|
||||||
metadata_client,
|
metadata_client,
|
||||||
poster_fetcher,
|
poster_fetcher,
|
||||||
object_storage,
|
object_storage,
|
||||||
@@ -44,7 +47,7 @@ impl PosterSyncHandler {
|
|||||||
movie_id: MovieId,
|
movie_id: MovieId,
|
||||||
external_metadata_id: ExternalMetadataId,
|
external_metadata_id: ExternalMetadataId,
|
||||||
) -> Result<(), DomainError> {
|
) -> 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,
|
Some(m) => m,
|
||||||
None => {
|
None => {
|
||||||
tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value());
|
tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value());
|
||||||
@@ -82,7 +85,7 @@ impl PosterSyncHandler {
|
|||||||
let poster_path = PosterPath::new(stored_path)?;
|
let poster_path = PosterPath::new(stored_path)?;
|
||||||
|
|
||||||
movie.update_poster(poster_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
|
if let Err(e) = self
|
||||||
.event_publisher
|
.event_publisher
|
||||||
@@ -115,7 +118,7 @@ impl EventHandler for PosterSyncHandler {
|
|||||||
} => {
|
} => {
|
||||||
// Only sync poster if the movie doesn't have one yet
|
// Only sync poster if the movie doesn't have one yet
|
||||||
let already_has_poster = self
|
let already_has_poster = self
|
||||||
.movie_repository
|
.movie_query
|
||||||
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
|
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
|
||||||
.await?
|
.await?
|
||||||
.map(|m| m.poster_path().is_some())
|
.map(|m| m.poster_path().is_some())
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
activitypub = { 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" }
|
||||||
|
postgres-social = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { 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};
|
mod activity;
|
||||||
use async_trait::async_trait;
|
mod actor;
|
||||||
use chrono::{NaiveDateTime, Utc};
|
mod blocklist;
|
||||||
|
mod follow;
|
||||||
|
mod review;
|
||||||
|
|
||||||
|
use k_ap::{FollowerStatus, RemoteActor};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
|
||||||
use activitypub::RemoteReviewRepository;
|
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
|
||||||
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 {
|
|
||||||
match status {
|
match status {
|
||||||
FollowerStatus::Pending => "pending",
|
FollowerStatus::Pending => "pending",
|
||||||
FollowerStatus::Accepted => "accepted",
|
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 {
|
match s {
|
||||||
"accepted" => FollowerStatus::Accepted,
|
"accepted" => FollowerStatus::Accepted,
|
||||||
"rejected" => FollowerStatus::Rejected,
|
"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 {
|
RemoteActor {
|
||||||
url: row.get(url_col),
|
url: row.get(url_col),
|
||||||
handle: row.try_get("handle").unwrap_or_default(),
|
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]
|
pub struct PostgresFederationRepository {
|
||||||
impl FollowRepository for PostgresFederationRepository {
|
pub(crate) pool: PgPool,
|
||||||
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(
|
impl PostgresFederationRepository {
|
||||||
&self,
|
pub fn new(pool: PgPool) -> Self {
|
||||||
local_user_id: uuid::Uuid,
|
Self { pool }
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
pub fn wire(
|
||||||
impl ActorRepository for PostgresFederationRepository {
|
pool: PgPool,
|
||||||
async fn get_local_actor_keypair(
|
instance: domain::value_objects::InstanceIdentity,
|
||||||
&self,
|
) -> activitypub::FederationRepos {
|
||||||
user_id: uuid::Uuid,
|
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone()));
|
||||||
) -> Result<Option<(String, String)>> {
|
let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new(
|
||||||
let uid = user_id.to_string();
|
pool, instance,
|
||||||
let row =
|
));
|
||||||
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
|
activitypub::FederationRepos {
|
||||||
.bind(&uid)
|
activity: std::sync::Arc::clone(&fed) as _,
|
||||||
.fetch_optional(&self.pool)
|
follow: std::sync::Arc::clone(&fed) as _,
|
||||||
.await?;
|
actor: std::sync::Arc::clone(&fed) as _,
|
||||||
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
|
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||||
}
|
review_store: fed as _,
|
||||||
|
admin_query: std::sync::Arc::clone(&social) as _,
|
||||||
async fn save_local_actor_keypair(
|
remote_watchlist: std::sync::Arc::clone(&social) as _,
|
||||||
&self,
|
follow_command: std::sync::Arc::clone(&social) as _,
|
||||||
user_id: uuid::Uuid,
|
follow_query: social as _,
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,8 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
|
async-trait = { workspace = true }
|
||||||
uuid = { 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]
|
#[async_trait]
|
||||||
impl SearchCommand for PostgresSearchAdapter {
|
impl SearchCommand for PostgresSearchAdapter {
|
||||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
||||||
@@ -91,7 +87,7 @@ impl SearchCommand for PostgresSearchAdapter {
|
|||||||
.bind(&fts_input)
|
.bind(&fts_input)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -113,7 +109,7 @@ impl SearchCommand for PostgresSearchAdapter {
|
|||||||
.bind(&fts_input)
|
.bind(&fts_input)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -127,14 +123,14 @@ impl SearchCommand for PostgresSearchAdapter {
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
EntityType::Person => {
|
EntityType::Person => {
|
||||||
sqlx::query("DELETE FROM people_search WHERE person_id = $1")
|
sqlx::query("DELETE FROM people_search WHERE person_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -183,7 +179,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(query.filters.year.map(|y| y as i32))
|
.bind(query.filters.year.map(|y| y as i32))
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
} else {
|
} else {
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
@@ -196,7 +192,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(query.filters.year.map(|y| y as i32))
|
.bind(query.filters.year.map(|y| y as i32))
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,7 +217,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, Row>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
@@ -238,7 +234,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
};
|
};
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
@@ -290,7 +286,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(text)
|
.bind(text)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -316,7 +312,7 @@ impl PostgresSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let mut items = Vec::with_capacity(rows.len());
|
let mut items = Vec::with_capacity(rows.len());
|
||||||
for row in rows {
|
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 async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DiaryEntry, Movie, Review, WatchlistEntry, WatchlistWithMovie},
|
models::{
|
||||||
|
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
|
||||||
|
WatchlistWithMovie,
|
||||||
|
},
|
||||||
ports::LocalApContentQuery,
|
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 sqlx::{PgPool, Row};
|
||||||
|
|
||||||
use crate::models::{DiaryRow, MovieRow, ReviewRow, parse_datetime, parse_uuid};
|
|
||||||
|
|
||||||
pub struct PostgresApContentQuery {
|
pub struct PostgresApContentQuery {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
}
|
}
|
||||||
@@ -17,38 +21,134 @@ impl PostgresApContentQuery {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
// ── Local row types ──────────────────────────────────────────────────────────
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
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]
|
#[async_trait]
|
||||||
impl LocalApContentQuery for PostgresApContentQuery {
|
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(
|
async fn get_local_watchlist_for_user(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
@@ -67,7 +167,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|row| {
|
.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,
|
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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
|
||||||
@@ -134,50 +235,10 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
|||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
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(
|
async fn get_local_reviews_page(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
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,
|
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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
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
|
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)
|
.bind(limit_i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, DiaryRow>(
|
sqlx::query_as::<_, DiaryRow>(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
|
||||||
@@ -224,35 +287,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
|
|||||||
.bind(limit_i64)
|
.bind(limit_i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
};
|
};
|
||||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
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 {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -38,7 +33,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
|||||||
.bind(&received)
|
.bind(&received)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -57,7 +52,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
|||||||
.bind(ap_id)
|
.bind(ap_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -68,7 +63,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -78,7 +73,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -92,7 +87,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.map(|r| {
|
.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",
|
"macros",
|
||||||
"chrono",
|
"chrono",
|
||||||
] }
|
] }
|
||||||
|
adapter-common = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
|
postgres-social = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { 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::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
|
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::DiaryRepository,
|
ports::DiaryQuery,
|
||||||
value_objects::{MovieId, UserId},
|
value_objects::{MovieId, UserId},
|
||||||
};
|
};
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
@@ -22,45 +22,41 @@ impl PostgresDiaryRepository {
|
|||||||
Self { pool }
|
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> {
|
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
|
||||||
match movie_id {
|
match movie_id {
|
||||||
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
|
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err),
|
.map_err(adapter_common::map_sqlx_error),
|
||||||
Some(id) => {
|
Some(id) => {
|
||||||
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
|
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_all_diary_rows(
|
async fn fetch_all_diary_rows(
|
||||||
&self,
|
&self,
|
||||||
sort: &SortDirection,
|
sort: &ReviewSortBy,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
let order = match sort {
|
let order = match sort {
|
||||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||||
SortDirection::Ascending => "r.watched_at ASC",
|
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||||
SortDirection::Descending => "r.watched_at DESC",
|
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
@@ -72,28 +68,29 @@ impl PostgresDiaryRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_movie_diary_rows(
|
async fn fetch_movie_diary_rows(
|
||||||
&self,
|
&self,
|
||||||
movie_id: &str,
|
movie_id: &str,
|
||||||
sort: &SortDirection,
|
sort: &ReviewSortBy,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
let order = match sort {
|
let order = match sort {
|
||||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||||
SortDirection::Ascending => "r.watched_at ASC",
|
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||||
SortDirection::Descending => "r.watched_at DESC",
|
ReviewSortBy::Descending => "r.watched_at DESC",
|
||||||
};
|
};
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.movie_id = $1
|
WHERE r.movie_id = $1
|
||||||
@@ -107,51 +104,63 @@ impl PostgresDiaryRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_user_diary_entries(
|
async fn count_user_diary_entries(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
) -> Result<i64, DomainError> {
|
) -> Result<i64, DomainError> {
|
||||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
let sql = if has_search {
|
let remote_clause = if include_remote {
|
||||||
"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()
|
|
||||||
} else {
|
} 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
|
"SELECT COUNT(*) FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1"
|
WHERE r.user_id = $1{remote_clause}{search_clause}"
|
||||||
.to_string()
|
);
|
||||||
};
|
|
||||||
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
|
||||||
if has_search {
|
if has_search {
|
||||||
q = q.bind(search.unwrap());
|
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(
|
async fn fetch_user_diary_rows(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
sort: &SortDirection,
|
sort: &ReviewSortBy,
|
||||||
search: Option<&str>,
|
search: Option<&str>,
|
||||||
|
include_remote: bool,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<DiaryRow>, DomainError> {
|
) -> Result<Vec<DiaryRow>, DomainError> {
|
||||||
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
|
||||||
let order_clause = match sort {
|
let order_clause = match sort {
|
||||||
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
|
||||||
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
|
||||||
SortDirection::Ascending => "r.watched_at ASC",
|
ReviewSortBy::Ascending => "r.watched_at ASC",
|
||||||
SortDirection::Descending => "r.watched_at DESC",
|
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;
|
||||||
let mut p: i32 = 1; // $1 is user_id
|
|
||||||
let search_clause = if has_search {
|
let search_clause = if has_search {
|
||||||
p += 1;
|
p += 1;
|
||||||
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
|
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,
|
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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1{}
|
WHERE r.user_id = $1{remote_clause}{search_clause}
|
||||||
ORDER BY {}
|
ORDER BY {order_clause}
|
||||||
LIMIT {} OFFSET {}",
|
LIMIT {limit_param} OFFSET {offset_param}",
|
||||||
search_clause, order_clause, limit_param, offset_param
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
|
||||||
@@ -185,12 +194,12 @@ impl PostgresDiaryRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl DiaryRepository for PostgresDiaryRepository {
|
impl DiaryQuery for PostgresDiaryRepository {
|
||||||
async fn query_diary(
|
async fn query_diary(
|
||||||
&self,
|
&self,
|
||||||
filter: &DiaryFilter,
|
filter: &DiaryFilter,
|
||||||
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
(None, Some(uid)) => {
|
(None, Some(uid)) => {
|
||||||
let uid_str = uid.value().to_string();
|
let uid_str = uid.value().to_string();
|
||||||
let search = filter.search.as_deref();
|
let search = filter.search.as_deref();
|
||||||
|
let inc = filter.include_remote;
|
||||||
tokio::try_join!(
|
tokio::try_join!(
|
||||||
self.count_user_diary_entries(&uid_str, search),
|
self.count_user_diary_entries(&uid_str, search, inc),
|
||||||
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
|
self.fetch_user_diary_rows(
|
||||||
|
&uid_str,
|
||||||
|
&filter.sort_by,
|
||||||
|
search,
|
||||||
|
inc,
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
)
|
||||||
)?
|
)?
|
||||||
}
|
}
|
||||||
(Some(_), Some(_)) => {
|
(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.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,
|
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,
|
||||||
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
LEFT JOIN users u ON u.id = r.user_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 {}
|
WHERE {}
|
||||||
ORDER BY {}
|
ORDER BY {}
|
||||||
LIMIT {} OFFSET {}",
|
LIMIT {} OFFSET {}",
|
||||||
@@ -352,7 +372,10 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
|
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_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
|
||||||
let rows = rows_q
|
let rows = rows_q
|
||||||
@@ -360,7 +383,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -385,7 +408,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
|
||||||
.into_domain()?;
|
.into_domain()?;
|
||||||
|
|
||||||
@@ -393,13 +416,14 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
"SELECT id, movie_id, user_id, rating, comment,
|
"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(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,
|
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",
|
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
|
||||||
)
|
)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ReviewRow::into_domain)
|
.map(ReviewRow::into_domain)
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.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,
|
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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1
|
WHERE r.user_id = $1
|
||||||
@@ -423,7 +448,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.into_iter().map(DiaryRow::into_domain).collect()
|
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,
|
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.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,
|
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
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1
|
WHERE r.user_id = $1
|
||||||
@@ -451,7 +477,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
while let Some(row) = futures::StreamExt::next(&mut rows).await {
|
while let Some(row) = futures::StreamExt::next(&mut rows).await {
|
||||||
yield match row {
|
yield match row {
|
||||||
Ok(r) => r.into_domain(),
|
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)
|
.bind(id_str)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
.map(MovieStatsRow::into_domain)
|
.map(MovieStatsRow::into_domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -491,7 +517,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(
|
let rows = sqlx::query_as::<_, FeedRow>(
|
||||||
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
|
"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.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,
|
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,
|
||||||
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
|
r.watch_medium,
|
||||||
WHEN u.email IS NOT NULL THEN u.email
|
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
|
||||||
ELSE r.user_id END AS user_email
|
ELSE COALESCE(u.email, r.user_id) END AS user_email
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
LEFT JOIN users u ON u.id = r.user_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
|
WHERE r.movie_id = $1
|
||||||
ORDER BY r.watched_at DESC
|
ORDER BY r.watched_at DESC
|
||||||
LIMIT $2 OFFSET $3",
|
LIMIT $2 OFFSET $3",
|
||||||
@@ -514,7 +541,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -534,7 +561,7 @@ impl DiaryRepository for PostgresDiaryRepository {
|
|||||||
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(count as u64)
|
Ok(count as u64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{Goal, GoalType},
|
models::{Goal, GoalType},
|
||||||
ports::GoalRepository,
|
ports::{GoalCommand, GoalQuery},
|
||||||
value_objects::{GoalId, UserId},
|
value_objects::{GoalId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
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 {
|
pub struct PostgresGoalRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
@@ -17,15 +17,10 @@ impl PostgresGoalRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl GoalRepository for PostgresGoalRepository {
|
impl GoalCommand for PostgresGoalRepository {
|
||||||
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
|
||||||
let id = goal.id().value().to_string();
|
let id = goal.id().value().to_string();
|
||||||
let user_id = goal.user_id().value().to_string();
|
let user_id = goal.user_id().value().to_string();
|
||||||
@@ -46,7 +41,7 @@ impl GoalRepository for PostgresGoalRepository {
|
|||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -60,7 +55,7 @@ impl GoalRepository for PostgresGoalRepository {
|
|||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound("Goal not found".into()));
|
return Err(DomainError::NotFound("Goal not found".into()));
|
||||||
@@ -77,14 +72,17 @@ impl GoalRepository for PostgresGoalRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound("Goal not found".into()));
|
return Err(DomainError::NotFound("Goal not found".into()));
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GoalQuery for PostgresGoalRepository {
|
||||||
async fn find_by_user_and_year(
|
async fn find_by_user_and_year(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
@@ -102,7 +100,7 @@ impl GoalRepository for PostgresGoalRepository {
|
|||||||
.bind(y)
|
.bind(y)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.map(|r| row_to_goal(&r)).transpose()
|
row.map(|r| row_to_goal(&r)).transpose()
|
||||||
}
|
}
|
||||||
@@ -118,14 +116,10 @@ impl GoalRepository for PostgresGoalRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter().map(row_to_goal).collect()
|
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(
|
pub(crate) async fn count_reviews_in_year(
|
||||||
|
|||||||
@@ -96,11 +96,6 @@ impl PostgresImportProfileRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("DB error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -118,7 +113,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.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> {
|
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
|
||||||
@@ -139,7 +134,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.into_iter()
|
rows.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
@@ -184,7 +179,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
|||||||
.bind(&id_str).bind(&uid_str)
|
.bind(&id_str).bind(&uid_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.map(|r| {
|
row.map(|r| {
|
||||||
Ok(ImportProfile {
|
Ok(ImportProfile {
|
||||||
@@ -212,6 +207,6 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ use domain::{
|
|||||||
models::{
|
models::{
|
||||||
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
|
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
|
||||||
import::{DomainField, ImportRow, RowResult, Transform},
|
import::{DomainField, ImportRow, RowResult, Transform},
|
||||||
import_session::PersistedImportSession,
|
|
||||||
},
|
},
|
||||||
ports::ImportSessionRepository,
|
ports::ImportSessionRepository,
|
||||||
value_objects::{ImportSessionId, UserId},
|
value_objects::{ImportSessionId, UserId},
|
||||||
@@ -203,11 +202,6 @@ impl PostgresImportSessionRepository {
|
|||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("DB error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn serialize_session(
|
fn serialize_session(
|
||||||
s: &ImportSession,
|
s: &ImportSession,
|
||||||
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
) -> Result<(String, Option<String>, Option<String>), DomainError> {
|
||||||
@@ -267,7 +261,7 @@ impl PostgresImportSessionRepository {
|
|||||||
Ok(js.into_iter().map(annotated_from_json).collect())
|
Ok(js.into_iter().map(annotated_from_json).collect())
|
||||||
})
|
})
|
||||||
.transpose()?;
|
.transpose()?;
|
||||||
Ok(ImportSession::from_persistence(PersistedImportSession {
|
Ok(ImportSession {
|
||||||
id: ImportSessionId::from_uuid(
|
id: ImportSessionId::from_uuid(
|
||||||
id.parse::<uuid::Uuid>()
|
id.parse::<uuid::Uuid>()
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||||
@@ -282,7 +276,7 @@ impl PostgresImportSessionRepository {
|
|||||||
row_results,
|
row_results,
|
||||||
created_at,
|
created_at,
|
||||||
expires_at,
|
expires_at,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +296,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get(
|
async fn get(
|
||||||
@@ -332,7 +326,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
|||||||
.bind(&uid_str)
|
.bind(&uid_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.map(|r| {
|
row.map(|r| {
|
||||||
Self::deserialize_session(
|
Self::deserialize_session(
|
||||||
@@ -360,7 +354,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
|
||||||
@@ -370,14 +364,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
async fn delete_expired(&self) -> Result<u64, DomainError> {
|
||||||
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()")
|
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,6 +382,6 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
mod ap_content;
|
|
||||||
mod diary;
|
mod diary;
|
||||||
mod goals;
|
mod goals;
|
||||||
mod image_ref;
|
mod image_ref;
|
||||||
@@ -9,11 +8,11 @@ mod import_profile;
|
|||||||
mod import_session;
|
mod import_session;
|
||||||
mod models;
|
mod models;
|
||||||
mod movie;
|
mod movie;
|
||||||
|
mod movie_dedup;
|
||||||
mod persons;
|
mod persons;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod profile_fields;
|
mod profile_fields;
|
||||||
mod refresh_sessions;
|
mod refresh_sessions;
|
||||||
mod remote_goals;
|
|
||||||
mod review;
|
mod review;
|
||||||
mod stats;
|
mod stats;
|
||||||
mod user_settings;
|
mod user_settings;
|
||||||
@@ -22,13 +21,14 @@ mod watch_event;
|
|||||||
mod watchlist;
|
mod watchlist;
|
||||||
mod wrapup;
|
mod wrapup;
|
||||||
|
|
||||||
pub use ap_content::PostgresApContentQuery;
|
|
||||||
pub use diary::PostgresDiaryRepository;
|
pub use diary::PostgresDiaryRepository;
|
||||||
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
|
pub use image_ref::{PostgresImageRefAdapter, create_image_ref};
|
||||||
pub use import_profile::PostgresImportProfileRepository;
|
pub use import_profile::PostgresImportProfileRepository;
|
||||||
pub use import_session::PostgresImportSessionRepository;
|
pub use import_session::PostgresImportSessionRepository;
|
||||||
pub use movie::PostgresMovieRepository;
|
pub use movie::PostgresMovieRepository;
|
||||||
|
pub use movie_dedup::PostgresMovieDeduplicator;
|
||||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||||
|
pub use postgres_social::PostgresApContentQuery;
|
||||||
pub use profile::PostgresMovieProfileRepository;
|
pub use profile::PostgresMovieProfileRepository;
|
||||||
pub use profile_fields::PostgresProfileFieldsRepository;
|
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||||
pub use refresh_sessions::PostgresRefreshSessionAdapter;
|
pub use refresh_sessions::PostgresRefreshSessionAdapter;
|
||||||
@@ -39,30 +39,6 @@ pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenReposito
|
|||||||
pub use watchlist::PostgresWatchlistRepository;
|
pub use watchlist::PostgresWatchlistRepository;
|
||||||
pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery};
|
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> {
|
pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> {
|
||||||
sqlx::migrate!("./migrations")
|
sqlx::migrate!("./migrations")
|
||||||
.set_ignore_missing(true)
|
.set_ignore_missing(true)
|
||||||
@@ -79,9 +55,10 @@ pub fn create_profile_fields_repo(
|
|||||||
|
|
||||||
pub struct PostgresWireOutput {
|
pub struct PostgresWireOutput {
|
||||||
pub pool: PgPool,
|
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 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 stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
|
||||||
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
|
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
|
||||||
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
|
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 ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
|
||||||
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
|
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
|
||||||
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
|
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 user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
|
||||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||||
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
|
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> {
|
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()),
|
user_settings::PostgresUserSettingsRepository::new(pool.clone()),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone()));
|
||||||
|
|
||||||
Ok(PostgresWireOutput {
|
Ok(PostgresWireOutput {
|
||||||
pool: pool.clone(),
|
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 _,
|
review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _,
|
||||||
diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _,
|
diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _,
|
||||||
stats: std::sync::Arc::new(PostgresStatsRepository::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 _,
|
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _,
|
||||||
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::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 _,
|
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 _,
|
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
|
||||||
federation_settings: user_settings_repo as _,
|
federation_settings: user_settings_repo as _,
|
||||||
remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool))
|
remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new(
|
||||||
as _,
|
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::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review},
|
||||||
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
|
|
||||||
UserSummary,
|
|
||||||
},
|
|
||||||
value_objects::{
|
|
||||||
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
|
|
||||||
ReviewId, UserId, Username,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
pub(crate) struct MovieRow {
|
pub(crate) struct MovieRow {
|
||||||
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
|
|||||||
|
|
||||||
impl MovieRow {
|
impl MovieRow {
|
||||||
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
pub fn into_domain(self) -> Result<Movie, DomainError> {
|
||||||
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
|
movie_row_to_domain(
|
||||||
let external_metadata_id = self
|
self.id,
|
||||||
.external_metadata_id
|
self.external_metadata_id,
|
||||||
.map(ExternalMetadataId::new)
|
self.title,
|
||||||
.transpose()?;
|
self.release_year,
|
||||||
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,
|
self.director,
|
||||||
poster_path,
|
self.poster_path,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,23 +47,22 @@ pub(crate) struct MovieSummaryRow {
|
|||||||
|
|
||||||
impl MovieSummaryRow {
|
impl MovieSummaryRow {
|
||||||
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
|
||||||
let movie = MovieRow {
|
let movie = movie_row_to_domain(
|
||||||
id: self.id,
|
self.id,
|
||||||
external_metadata_id: self.external_metadata_id,
|
self.external_metadata_id,
|
||||||
title: self.title,
|
self.title,
|
||||||
release_year: self.release_year,
|
self.release_year,
|
||||||
director: self.director,
|
self.director,
|
||||||
poster_path: self.poster_path,
|
self.poster_path,
|
||||||
}
|
)?;
|
||||||
.into_domain()?;
|
Ok(movie_summary_to_domain(
|
||||||
Ok(MovieSummary {
|
|
||||||
movie,
|
movie,
|
||||||
genres: self.genres.unwrap_or_default(),
|
self.genres.unwrap_or_default(),
|
||||||
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
|
self.runtime_minutes,
|
||||||
original_language: self.original_language,
|
self.original_language,
|
||||||
overview: self.overview,
|
self.overview,
|
||||||
collection_name: self.collection_name,
|
self.collection_name,
|
||||||
})
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,31 +76,22 @@ pub(crate) struct ReviewRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ReviewRow {
|
impl ReviewRow {
|
||||||
pub fn into_domain(self) -> Result<Review, DomainError> {
|
pub fn into_domain(self) -> Result<Review, DomainError> {
|
||||||
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
|
review_row_to_domain(
|
||||||
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
|
self.id,
|
||||||
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
|
self.movie_id,
|
||||||
let rating = Rating::new(self.rating as u8)?;
|
self.user_id,
|
||||||
let comment = self.comment.map(Comment::new).transpose()?;
|
self.rating,
|
||||||
let watched_at = parse_datetime(&self.watched_at)?;
|
self.comment,
|
||||||
let created_at = parse_datetime(&self.created_at)?;
|
self.watched_at,
|
||||||
let source = match self.remote_actor_url {
|
self.created_at,
|
||||||
None => ReviewSource::Local,
|
self.remote_actor_url,
|
||||||
Some(url) => ReviewSource::Remote { actor_url: url },
|
self.watch_medium,
|
||||||
};
|
)
|
||||||
Ok(Review::from_persistence(PersistedReview {
|
|
||||||
id,
|
|
||||||
movie_id,
|
|
||||||
user_id,
|
|
||||||
rating,
|
|
||||||
comment,
|
|
||||||
watched_at,
|
|
||||||
created_at,
|
|
||||||
source,
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,30 +111,30 @@ pub(crate) struct DiaryRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DiaryRow {
|
impl DiaryRow {
|
||||||
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
|
||||||
let movie = MovieRow {
|
let movie = movie_row_to_domain(
|
||||||
id: self.id,
|
self.id,
|
||||||
external_metadata_id: self.external_metadata_id,
|
self.external_metadata_id,
|
||||||
title: self.title,
|
self.title,
|
||||||
release_year: self.release_year,
|
self.release_year,
|
||||||
director: self.director,
|
self.director,
|
||||||
poster_path: self.poster_path,
|
self.poster_path,
|
||||||
}
|
)?;
|
||||||
.into_domain()?;
|
let review = review_row_to_domain(
|
||||||
let review = ReviewRow {
|
self.review_id,
|
||||||
id: self.review_id,
|
self.movie_id,
|
||||||
movie_id: self.movie_id,
|
self.user_id,
|
||||||
user_id: self.user_id,
|
self.rating,
|
||||||
rating: self.rating,
|
self.comment,
|
||||||
comment: self.comment,
|
self.watched_at,
|
||||||
watched_at: self.watched_at,
|
self.created_at,
|
||||||
created_at: self.created_at,
|
self.remote_actor_url,
|
||||||
remote_actor_url: self.remote_actor_url,
|
self.watch_medium,
|
||||||
}
|
)?;
|
||||||
.into_domain()?;
|
|
||||||
Ok(DiaryEntry::new(movie, review))
|
Ok(DiaryEntry::new(movie, review))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,28 +155,32 @@ pub(crate) struct FeedRow {
|
|||||||
pub watched_at: String,
|
pub watched_at: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub remote_actor_url: Option<String>,
|
pub remote_actor_url: Option<String>,
|
||||||
|
pub watch_medium: Option<String>,
|
||||||
pub user_email: String,
|
pub user_email: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedRow {
|
impl FeedRow {
|
||||||
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
|
||||||
let diary = DiaryRow {
|
let movie = movie_row_to_domain(
|
||||||
id: self.id,
|
self.id,
|
||||||
external_metadata_id: self.external_metadata_id,
|
self.external_metadata_id,
|
||||||
title: self.title,
|
self.title,
|
||||||
release_year: self.release_year,
|
self.release_year,
|
||||||
director: self.director,
|
self.director,
|
||||||
poster_path: self.poster_path,
|
self.poster_path,
|
||||||
review_id: self.review_id,
|
)?;
|
||||||
movie_id: self.movie_id,
|
let review = review_row_to_domain(
|
||||||
user_id: self.user_id,
|
self.review_id,
|
||||||
rating: self.rating,
|
self.movie_id,
|
||||||
comment: self.comment,
|
self.user_id,
|
||||||
watched_at: self.watched_at,
|
self.rating,
|
||||||
created_at: self.created_at,
|
self.comment,
|
||||||
remote_actor_url: self.remote_actor_url,
|
self.watched_at,
|
||||||
}
|
self.created_at,
|
||||||
.into_domain()?;
|
self.remote_actor_url,
|
||||||
|
self.watch_medium,
|
||||||
|
)?;
|
||||||
|
let diary = DiaryEntry::new(movie, review);
|
||||||
Ok(FeedEntry::new(diary, self.user_email))
|
Ok(FeedEntry::new(diary, self.user_email))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,18 +199,18 @@ pub(crate) struct MovieStatsRow {
|
|||||||
|
|
||||||
impl MovieStatsRow {
|
impl MovieStatsRow {
|
||||||
pub fn into_domain(self) -> domain::models::MovieStats {
|
pub fn into_domain(self) -> domain::models::MovieStats {
|
||||||
domain::models::MovieStats {
|
movie_stats_to_domain(
|
||||||
total_count: self.total_count as u64,
|
self.total_count,
|
||||||
avg_rating: self.avg_rating,
|
self.avg_rating,
|
||||||
federated_count: self.federated_count as u64,
|
self.federated_count,
|
||||||
rating_histogram: [
|
[
|
||||||
self.rating_1 as u64,
|
self.rating_1,
|
||||||
self.rating_2 as u64,
|
self.rating_2,
|
||||||
self.rating_3 as u64,
|
self.rating_3,
|
||||||
self.rating_4 as u64,
|
self.rating_4,
|
||||||
self.rating_5 as u64,
|
self.rating_5,
|
||||||
],
|
],
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,16 +226,16 @@ pub(crate) struct UserSummaryRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserSummaryRow {
|
impl UserSummaryRow {
|
||||||
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
|
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
|
||||||
Ok(UserSummary::new(
|
user_summary_to_domain(
|
||||||
UserId::from_uuid(parse_uuid(&self.id)?),
|
self.id,
|
||||||
Email::new(self.email)?,
|
self.email,
|
||||||
Username::new(self.username)?,
|
self.username,
|
||||||
self.display_name,
|
self.display_name,
|
||||||
self.total_movies,
|
self.total_movies,
|
||||||
self.avg_rating,
|
self.avg_rating,
|
||||||
self.avatar_path,
|
self.avatar_path,
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +258,29 @@ pub(crate) struct MonthlyRatingRow {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
|
#[derive(sqlx::FromRow)]
|
||||||
Uuid::parse_str(s)
|
pub(crate) struct GenreCountRow {
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
|
pub genre: String,
|
||||||
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
|
#[derive(sqlx::FromRow)]
|
||||||
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
pub(crate) struct RatingDistRow {
|
||||||
|
pub rating: i64,
|
||||||
|
pub count: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
|
#[derive(sqlx::FromRow)]
|
||||||
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
|
pub(crate) struct WatchMediumCountRow {
|
||||||
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
|
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,
|
Movie, MovieFilter, MovieSummary,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::MovieRepository,
|
ports::{MovieCommand, MovieQuery},
|
||||||
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -20,67 +20,10 @@ impl PostgresMovieRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl MovieRepository for PostgresMovieRepository {
|
impl MovieCommand 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()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
|
||||||
let id = movie.id().value().to_string();
|
let id = movie.id().value().to_string();
|
||||||
let external_metadata_id = movie.external_metadata_id().map(|e| e.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)
|
.bind(&poster_path)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -118,9 +61,64 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
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(
|
async fn existing_external_ids(
|
||||||
&self,
|
&self,
|
||||||
@@ -136,7 +134,7 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
.bind(&vals)
|
.bind(&vals)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +157,7 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
.bind(&years)
|
.bind(&years)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
@@ -208,7 +206,7 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let total: i64 = sqlx::query(
|
let total: i64 = sqlx::query(
|
||||||
"SELECT COUNT(DISTINCT m.id) \
|
"SELECT COUNT(DISTINCT m.id) \
|
||||||
@@ -223,7 +221,7 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
.bind(genre)
|
.bind(genre)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.try_get(0)
|
.try_get(0)
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
@@ -239,4 +237,17 @@ impl MovieRepository for PostgresMovieRepository {
|
|||||||
offset: page.offset,
|
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]
|
#[async_trait]
|
||||||
impl PersonCommand for PostgresPersonAdapter {
|
impl PersonCommand for PostgresPersonAdapter {
|
||||||
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
|
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
|
||||||
@@ -56,7 +52,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
|||||||
.bind(person.profile_path())
|
.bind(person.profile_path())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -89,7 +85,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
|||||||
.bind(batch_size as i64)
|
.bind(batch_size as i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let has_more = rows.len() as u32 >= batch_size;
|
let has_more = rows.len() as u32 >= batch_size;
|
||||||
let mut count = 0u64;
|
let mut count = 0u64;
|
||||||
@@ -109,7 +105,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
|||||||
.bind(&row.profile_path)
|
.bind(&row.profile_path)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
Ok((count, has_more))
|
Ok((count, has_more))
|
||||||
@@ -137,7 +133,7 @@ impl PersonCommand for PostgresPersonAdapter {
|
|||||||
.bind(id.value().to_string())
|
.bind(id.value().to_string())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,7 +147,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.bind(id.value().to_string())
|
.bind(id.value().to_string())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(row.map(PersonRow::into_person))
|
Ok(row.map(PersonRow::into_person))
|
||||||
}
|
}
|
||||||
@@ -166,7 +162,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.bind(id.value())
|
.bind(id.value())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(row.map(PersonRow::into_person))
|
Ok(row.map(PersonRow::into_person))
|
||||||
}
|
}
|
||||||
@@ -182,7 +178,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.bind(id.value().to_string())
|
.bind(id.value().to_string())
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let Some(tmdb_id) = tmdb_id else {
|
let Some(tmdb_id) = tmdb_id else {
|
||||||
@@ -219,7 +215,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
.bind(tmdb_id)
|
.bind(tmdb_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| CastCredit {
|
.map(|r| CastCredit {
|
||||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
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)
|
.bind(tmdb_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| CrewCredit {
|
.map(|r| CrewCredit {
|
||||||
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
|
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)
|
.bind(offset as i64)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
Ok(rows.into_iter().map(PersonRow::into_person).collect())
|
||||||
}
|
}
|
||||||
@@ -279,7 +275,7 @@ impl PersonQuery for PostgresPersonAdapter {
|
|||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ impl PostgresMovieProfileRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -28,7 +23,11 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
|
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
|
||||||
let movie_id = p.movie_id.value().to_string();
|
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(
|
sqlx::query(
|
||||||
r#"INSERT INTO movie_profiles
|
r#"INSERT INTO movie_profiles
|
||||||
@@ -61,35 +60,35 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(p.enriched_at)
|
.bind(p.enriched_at)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
|
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
for g in &p.genres {
|
for g in &p.genres {
|
||||||
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
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)
|
.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")
|
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
for k in &p.keywords {
|
for k in &p.keywords {
|
||||||
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
|
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)
|
.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")
|
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
for c in &p.cast {
|
for c in &p.cast {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO movie_cast \
|
"INSERT INTO movie_cast \
|
||||||
@@ -104,14 +103,14 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&c.profile_path)
|
.bind(&c.profile_path)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
|
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
for cr in &p.crew {
|
for cr in &p.crew {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO movie_crew \
|
"INSERT INTO movie_crew \
|
||||||
@@ -126,10 +125,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&cr.profile_path)
|
.bind(&cr.profile_path)
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.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> {
|
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
|
||||||
@@ -144,7 +143,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let row = match row {
|
let row = match row {
|
||||||
Some(r) => r,
|
Some(r) => r,
|
||||||
@@ -159,7 +158,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| Genre {
|
.map(|r| Genre {
|
||||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||||
@@ -171,7 +170,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| Keyword {
|
.map(|r| Keyword {
|
||||||
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
|
||||||
@@ -186,14 +185,17 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| CastMember {
|
.map(|r| CastMember {
|
||||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||||
name: r.try_get("name").unwrap_or_default(),
|
name: r.try_get("name").unwrap_or_default(),
|
||||||
character: r.try_get("character").unwrap_or_default(),
|
character: r.try_get("character").unwrap_or_default(),
|
||||||
billing_order: r.try_get::<i32, _>("billing_order").unwrap_or(0) as u32,
|
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();
|
.collect();
|
||||||
|
|
||||||
@@ -204,38 +206,47 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| CrewMember {
|
.map(|r| CrewMember {
|
||||||
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
|
||||||
name: r.try_get("name").unwrap_or_default(),
|
name: r.try_get("name").unwrap_or_default(),
|
||||||
job: r.try_get("job").unwrap_or_default(),
|
job: r.try_get("job").unwrap_or_default(),
|
||||||
department: r.try_get("department").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();
|
.collect();
|
||||||
|
|
||||||
Ok(Some(MovieProfile {
|
Ok(Some(MovieProfile {
|
||||||
movie_id: id.clone(),
|
movie_id: id.clone(),
|
||||||
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
|
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
|
||||||
imdb_id: row.try_get("imdb_id").ok(),
|
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
|
||||||
overview: row.try_get("overview").ok(),
|
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
|
||||||
tagline: row.try_get("tagline").ok(),
|
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
|
||||||
runtime_minutes: row
|
runtime_minutes: row
|
||||||
.try_get::<Option<i32>, _>("runtime_minutes")
|
.try_get::<Option<i32>, _>("runtime_minutes")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|v| v as u32),
|
.map(|v| v as u32),
|
||||||
budget_usd: row.try_get("budget_usd").ok(),
|
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
|
||||||
revenue_usd: row.try_get("revenue_usd").ok(),
|
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
|
||||||
vote_average: row.try_get("vote_average").ok(),
|
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
|
||||||
vote_count: row
|
vote_count: row
|
||||||
.try_get::<Option<i32>, _>("vote_count")
|
.try_get::<Option<i32>, _>("vote_count")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|v| v as u32),
|
.map(|v| v as u32),
|
||||||
original_language: row.try_get("original_language").ok(),
|
original_language: row
|
||||||
collection_name: row.try_get("collection_name").ok(),
|
.try_get::<Option<String>, _>("original_language")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
|
collection_name: row
|
||||||
|
.try_get::<Option<String>, _>("collection_name")
|
||||||
|
.ok()
|
||||||
|
.flatten(),
|
||||||
genres,
|
genres,
|
||||||
keywords,
|
keywords,
|
||||||
cast,
|
cast,
|
||||||
@@ -257,7 +268,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
.bind(threshold)
|
.bind(threshold)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -16,10 +16,6 @@ impl PostgresRefreshSessionAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
DomainError::InfrastructureError(e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
||||||
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
|
||||||
@@ -34,7 +30,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
|||||||
.bind(session.created_at)
|
.bind(session.created_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +44,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
|||||||
.bind(token)
|
.bind(token)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.map(RefreshSessionRow::into_domain).transpose()
|
row.map(RefreshSessionRow::into_domain).transpose()
|
||||||
}
|
}
|
||||||
@@ -58,7 +54,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
|||||||
.bind(token)
|
.bind(token)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +63,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
|||||||
.bind(user_id.value().to_string())
|
.bind(user_id.value().to_string())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
|
|||||||
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
|
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
events::DomainEvent,
|
|
||||||
models::{Review, ReviewSource},
|
models::{Review, ReviewSource},
|
||||||
ports::ReviewRepository,
|
ports::ReviewRepository,
|
||||||
value_objects::{ReviewId, UserId},
|
value_objects::{ReviewId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::models::{ReviewRow, datetime_to_str};
|
use crate::models::ReviewRow;
|
||||||
|
use adapter_common::datetime_to_str;
|
||||||
|
|
||||||
pub struct PostgresReviewRepository {
|
pub struct PostgresReviewRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
@@ -18,16 +18,11 @@ impl PostgresReviewRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl ReviewRepository for PostgresReviewRepository {
|
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 id = review.id().value().to_string();
|
||||||
let movie_id = review.movie_id().value().to_string();
|
let movie_id = review.movie_id().value().to_string();
|
||||||
let user_id = review.user_id().value().to_string();
|
let user_id = review.user_id().value().to_string();
|
||||||
@@ -41,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
};
|
};
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
|
"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)",
|
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
@@ -52,17 +47,12 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
.bind(&watched_at)
|
.bind(&watched_at)
|
||||||
.bind(&created_at)
|
.bind(&created_at)
|
||||||
.bind(&remote_actor_url)
|
.bind(&remote_actor_url)
|
||||||
|
.bind(review.watch_medium().map(|wm| wm.to_string()))
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(DomainEvent::ReviewLogged {
|
Ok(())
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
|
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,
|
"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(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,
|
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",
|
FROM reviews WHERE id = $1",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.map(ReviewRow::into_domain)
|
.map(ReviewRow::into_domain)
|
||||||
.transpose()
|
.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> {
|
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
|
||||||
let id = review_id.value().to_string();
|
let id = review_id.value().to_string();
|
||||||
sqlx::query("DELETE FROM reviews WHERE id = $1")
|
sqlx::query("DELETE FROM reviews WHERE id = $1")
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +111,14 @@ impl ReviewRepository for PostgresReviewRepository {
|
|||||||
"SELECT id, movie_id, user_id, rating, comment,
|
"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(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,
|
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",
|
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(ReviewRow::into_domain)
|
.map(ReviewRow::into_domain)
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
|
models::{
|
||||||
|
ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors,
|
||||||
|
},
|
||||||
ports::StatsRepository,
|
ports::StatsRepository,
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::format_year_month;
|
use crate::models::{
|
||||||
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
|
ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow,
|
||||||
|
UserTotalsRow, WatchMediumCountRow,
|
||||||
|
};
|
||||||
|
use adapter_common::format_year_month;
|
||||||
|
|
||||||
pub struct PostgresStatsRepository {
|
pub struct PostgresStatsRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
@@ -19,11 +24,6 @@ impl PostgresStatsRepository {
|
|||||||
Self { pool }
|
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> {
|
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
|
||||||
sqlx::query_as::<_, UserTotalsRow>(
|
sqlx::query_as::<_, UserTotalsRow>(
|
||||||
r#"SELECT COUNT(DISTINCT movie_id) AS total,
|
r#"SELECT COUNT(DISTINCT movie_id) AS total,
|
||||||
@@ -33,7 +33,7 @@ impl PostgresStatsRepository {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_user_favorite_director(
|
async fn fetch_user_favorite_director(
|
||||||
@@ -52,7 +52,7 @@ impl PostgresStatsRepository {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_user_most_active_month(
|
async fn fetch_user_most_active_month(
|
||||||
@@ -70,7 +70,7 @@ impl PostgresStatsRepository {
|
|||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)
|
.map_err(adapter_common::map_sqlx_error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,34 +95,78 @@ 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> {
|
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
|
||||||
let uid = user_id.value().to_string();
|
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) =
|
||||||
sqlx::query_as::<_, MonthlyRatingRow>(
|
tokio::try_join!(
|
||||||
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
sqlx::query_as::<_, MonthlyRatingRow>(
|
||||||
|
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
|
||||||
AVG(rating::float) AS avg_rating,
|
AVG(rating::float) AS avg_rating,
|
||||||
COUNT(*) AS count
|
COUNT(*) AS count
|
||||||
FROM reviews
|
FROM reviews
|
||||||
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
|
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
|
||||||
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
|
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
|
||||||
ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
|
ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool),
|
.fetch_all(&self.pool),
|
||||||
sqlx::query_as::<_, DirectorCountRow>(
|
sqlx::query_as::<_, DirectorCountRow>(
|
||||||
"SELECT m.director AS director, COUNT(*) AS count
|
"SELECT m.director AS director, COUNT(*) AS count
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
INNER JOIN movies m ON m.id = r.movie_id
|
INNER JOIN movies m ON m.id = r.movie_id
|
||||||
WHERE r.user_id = $1 AND m.director IS NOT NULL
|
WHERE r.user_id = $1 AND m.director IS NOT NULL
|
||||||
GROUP BY m.director
|
GROUP BY m.director
|
||||||
ORDER BY COUNT(*) DESC
|
ORDER BY COUNT(*) DESC
|
||||||
LIMIT 5"
|
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)
|
||||||
)
|
)
|
||||||
.bind(&uid)
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
.fetch_all(&self.pool)
|
|
||||||
)
|
|
||||||
.map_err(Self::map_err)?;
|
|
||||||
|
|
||||||
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
|
||||||
|
|
||||||
@@ -144,10 +188,52 @@ impl StatsRepository for PostgresStatsRepository {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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 {
|
Ok(UserTrends {
|
||||||
monthly_ratings,
|
monthly_ratings,
|
||||||
top_directors,
|
top_directors,
|
||||||
max_director_count,
|
max_director_count,
|
||||||
|
top_genres,
|
||||||
|
rating_distribution,
|
||||||
|
watch_medium_distribution,
|
||||||
|
top_actors,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ impl PostgresUserSettingsRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -33,7 +28,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
@@ -65,7 +60,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
|
|||||||
.bind(settings.federate_watchlist())
|
.bind(settings.federate_watchlist())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +76,7 @@ impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
match row {
|
match row {
|
||||||
Some(r) => {
|
Some(r) => {
|
||||||
|
|||||||
@@ -20,11 +20,6 @@ impl PostgresUserRepository {
|
|||||||
Self { pool }
|
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 {
|
fn parse_role(s: &str) -> UserRole {
|
||||||
match s {
|
match s {
|
||||||
"admin" => UserRole::Admin,
|
"admin" => UserRole::Admin,
|
||||||
@@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.bind(email_str)
|
.bind(email_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
row.as_ref()
|
row.as_ref()
|
||||||
.map(|r| Self::row_to_user(r, vec![]))
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -90,7 +85,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.bind(username_str)
|
.bind(username_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
row.as_ref()
|
row.as_ref()
|
||||||
.map(|r| Self::row_to_user(r, vec![]))
|
.map(|r| Self::row_to_user(r, vec![]))
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -130,7 +125,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.bind(role)
|
.bind(role)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +135,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let Some(r) = row else { return Ok(None) };
|
let Some(r) = row else { return Ok(None) };
|
||||||
|
|
||||||
@@ -197,7 +192,7 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(UserSummaryRow::into_domain)
|
.map(UserSummaryRow::into_domain)
|
||||||
.collect()
|
.collect()
|
||||||
|
|||||||
@@ -2,17 +2,12 @@ use async_trait::async_trait;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
|
||||||
ports::{WatchEventRepository, WebhookTokenRepository},
|
ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
|
||||||
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
|
||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
|
|
||||||
use crate::models::{parse_datetime, parse_uuid};
|
use adapter_common::{parse_datetime, parse_uuid};
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── WatchEventRepository ──────────────────────────────────────────────────────
|
// ── WatchEventRepository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -27,7 +22,7 @@ impl PostgresWatchEventRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl WatchEventRepository for PostgresWatchEventRepository {
|
impl WatchEventCommand for PostgresWatchEventRepository {
|
||||||
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
|
||||||
let id = event.id().value().to_string();
|
let id = event.id().value().to_string();
|
||||||
let user_id = event.user_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())
|
.bind(event.created_at())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -70,11 +65,46 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
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> {
|
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
|
||||||
let uid = user_id.value().to_string();
|
let uid = user_id.value().to_string();
|
||||||
|
|
||||||
@@ -91,7 +121,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter().map(row_to_watch_event).collect()
|
rows.iter().map(row_to_watch_event).collect()
|
||||||
}
|
}
|
||||||
@@ -110,7 +140,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.as_ref().map(row_to_watch_event).transpose()
|
row.as_ref().map(row_to_watch_event).transpose()
|
||||||
}
|
}
|
||||||
@@ -131,29 +161,10 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
|||||||
.bind(&id_strs)
|
.bind(&id_strs)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
rows.iter().map(row_to_watch_event).collect()
|
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(
|
async fn find_duplicate(
|
||||||
&self,
|
&self,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
@@ -171,36 +182,41 @@ impl WatchEventRepository for PostgresWatchEventRepository {
|
|||||||
.bind(after)
|
.bind(after)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(count > 0)
|
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> {
|
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 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(map_err)?;
|
let user_id_str: String = row
|
||||||
let movie_id_str: Option<String> = row.try_get("movie_id").map_err(map_err)?;
|
.try_get("user_id")
|
||||||
let title: String = row.try_get("title").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let year: Option<i32> = row.try_get("year").map_err(map_err)?;
|
let movie_id_str: Option<String> = row
|
||||||
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?;
|
.try_get("movie_id")
|
||||||
let source_str: String = row.try_get("source").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
let title: String = row
|
||||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
.try_get("title")
|
||||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
.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
|
let source: WatchEventSource = source_str
|
||||||
.parse()
|
.parse()
|
||||||
@@ -262,7 +278,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
|||||||
.bind(token.last_used_at())
|
.bind(token.last_used_at())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -277,7 +293,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
|||||||
.bind(hash)
|
.bind(hash)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.as_ref().map(row_to_webhook_token).transpose()
|
row.as_ref().map(row_to_webhook_token).transpose()
|
||||||
}
|
}
|
||||||
@@ -294,7 +310,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter().map(row_to_webhook_token).collect()
|
rows.iter().map(row_to_webhook_token).collect()
|
||||||
}
|
}
|
||||||
@@ -308,7 +324,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
|
||||||
@@ -323,20 +339,32 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> {
|
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 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(map_err)?;
|
let user_id_str: String = row
|
||||||
let token_hash: String = row.try_get("token_hash").map_err(map_err)?;
|
.try_get("user_id")
|
||||||
let provider_str: String = row.try_get("provider").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let label: Option<String> = row.try_get("label").map_err(map_err)?;
|
let token_hash: String = row
|
||||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
.try_get("token_hash")
|
||||||
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?;
|
.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
|
let provider: WatchEventSource = provider_str
|
||||||
.parse()
|
.parse()
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use domain::{
|
|||||||
};
|
};
|
||||||
use sqlx::{PgPool, Row};
|
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 {
|
pub struct PostgresWatchlistRepository {
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
@@ -20,11 +21,6 @@ impl PostgresWatchlistRepository {
|
|||||||
pub fn new(pool: PgPool) -> Self {
|
pub fn new(pool: PgPool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -46,7 +42,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
|||||||
.bind(added_at)
|
.bind(added_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -61,7 +57,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
|||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
if result.rows_affected() == 0 {
|
if result.rows_affected() == 0 {
|
||||||
return Err(DomainError::NotFound(format!(
|
return Err(DomainError::NotFound(format!(
|
||||||
@@ -85,7 +81,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
|||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(result.rows_affected() > 0)
|
Ok(result.rows_affected() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,14 +110,14 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let total: i64 =
|
let total: i64 =
|
||||||
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
|
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -187,7 +183,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
|
|||||||
.bind(&mid)
|
.bind(&mid)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(count > 0)
|
Ok(count > 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,7 @@ use domain::{
|
|||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::{parse_datetime, parse_uuid};
|
use adapter_common::{parse_datetime, parse_uuid};
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_to_str(s: &WrapUpStatus) -> &'static str {
|
fn status_to_str(s: &WrapUpStatus) -> &'static str {
|
||||||
match s {
|
match s {
|
||||||
@@ -76,7 +71,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(record.completed_at)
|
.bind(record.completed_at)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -96,7 +91,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -115,7 +110,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -132,7 +127,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.as_ref().map(row_to_record).transpose()
|
row.as_ref().map(row_to_record).transpose()
|
||||||
}
|
}
|
||||||
@@ -149,7 +144,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter().map(row_to_record).collect()
|
rows.iter().map(row_to_record).collect()
|
||||||
}
|
}
|
||||||
@@ -163,7 +158,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
)
|
)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter().map(row_to_record).collect()
|
rows.iter().map(row_to_record).collect()
|
||||||
}
|
}
|
||||||
@@ -190,7 +185,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(end)
|
.bind(end)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
row.as_ref().map(row_to_record).transpose()
|
row.as_ref().map(row_to_record).transpose()
|
||||||
}
|
}
|
||||||
@@ -200,7 +195,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(id.value().to_string())
|
.bind(id.value().to_string())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,21 +208,37 @@ impl WrapUpRepository for PostgresWrapUpRepository {
|
|||||||
.bind(before)
|
.bind(before)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> {
|
fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> {
|
||||||
let id_str: String = row.try_get("id").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(map_err)?;
|
let user_id_str: Option<String> = row
|
||||||
let start_date: NaiveDate = row.try_get("start_date").map_err(map_err)?;
|
.try_get("user_id")
|
||||||
let end_date: NaiveDate = row.try_get("end_date").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let status_str: String = row.try_get("status").map_err(map_err)?;
|
let start_date: NaiveDate = row
|
||||||
let report_json: Option<String> = row.try_get("report_json").map_err(map_err)?;
|
.try_get("start_date")
|
||||||
let error_message: Option<String> = row.try_get("error_message").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
|
let end_date: NaiveDate = row
|
||||||
let completed_at_str: Option<String> = row.try_get("completed_at").map_err(map_err)?;
|
.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()?;
|
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, \
|
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
|
||||||
r.rating, \
|
r.rating, \
|
||||||
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
|
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 \
|
p.runtime_minutes, p.budget_usd, p.original_language \
|
||||||
FROM reviews r \
|
FROM reviews r \
|
||||||
INNER JOIN movies m ON m.id = r.movie_id \
|
INNER JOIN movies m ON m.id = r.movie_id \
|
||||||
@@ -292,7 +303,10 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
|||||||
q = q.bind(uid);
|
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() {
|
if rows.is_empty() {
|
||||||
return Ok(vec![]);
|
return Ok(vec![]);
|
||||||
@@ -302,7 +316,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
|||||||
let mut movie_ids: Vec<String> = Vec::new();
|
let mut movie_ids: Vec<String> = Vec::new();
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
for row in &rows {
|
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()) {
|
if seen.insert(mid.clone()) {
|
||||||
movie_ids.push(mid);
|
movie_ids.push(mid);
|
||||||
}
|
}
|
||||||
@@ -318,18 +334,42 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
|
|||||||
// 3) Build result
|
// 3) Build result
|
||||||
let mut result = Vec::with_capacity(rows.len());
|
let mut result = Vec::with_capacity(rows.len());
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
let movie_id_str: String = row.try_get("movie_id").map_err(map_err)?;
|
let movie_id_str: String = row
|
||||||
let title: String = row.try_get("title").map_err(map_err)?;
|
.try_get("movie_id")
|
||||||
let release_year: i64 = row.try_get("release_year").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let director: Option<String> = row.try_get("director").map_err(map_err)?;
|
let title: String = row
|
||||||
let poster_path: Option<String> = row.try_get("poster_path").map_err(map_err)?;
|
.try_get("title")
|
||||||
let rating: i64 = row.try_get("rating").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
|
let release_year: i64 = row
|
||||||
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
|
.try_get("release_year")
|
||||||
let runtime_minutes: Option<i32> = row.try_get("runtime_minutes").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let budget_usd: Option<i64> = row.try_get("budget_usd").map_err(map_err)?;
|
let director: Option<String> = row
|
||||||
let original_language: Option<String> =
|
.try_get("director")
|
||||||
row.try_get("original_language").map_err(map_err)?;
|
.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 genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
|
||||||
let keywords = keywords_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),
|
runtime_minutes: runtime_minutes.map(|v| v as u32),
|
||||||
budget_usd,
|
budget_usd,
|
||||||
original_language,
|
original_language,
|
||||||
|
watch_medium,
|
||||||
genres,
|
genres,
|
||||||
keywords,
|
keywords,
|
||||||
cast_names,
|
cast_names,
|
||||||
@@ -383,12 +424,16 @@ async fn fetch_genres_pg(
|
|||||||
.bind(movie_ids)
|
.bind(movie_ids)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
let mid: String = row
|
||||||
let name: String = row.try_get("name").map_err(map_err)?;
|
.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);
|
map.entry(mid).or_default().push(name);
|
||||||
}
|
}
|
||||||
Ok(map)
|
Ok(map)
|
||||||
@@ -404,12 +449,16 @@ async fn fetch_keywords_pg(
|
|||||||
.bind(movie_ids)
|
.bind(movie_ids)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
let mut map: HashMap<String, Vec<String>> = HashMap::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
let mid: String = row
|
||||||
let name: String = row.try_get("name").map_err(map_err)?;
|
.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);
|
map.entry(mid).or_default().push(name);
|
||||||
}
|
}
|
||||||
Ok(map)
|
Ok(map)
|
||||||
@@ -428,15 +477,25 @@ async fn fetch_cast_pg(
|
|||||||
.bind(movie_ids)
|
.bind(movie_ids)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
|
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let mid: String = row.try_get("movie_id").map_err(map_err)?;
|
let mid: String = row
|
||||||
let name: String = row.try_get("name").map_err(map_err)?;
|
.try_get("movie_id")
|
||||||
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?;
|
let name: String = row
|
||||||
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?;
|
.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 {
|
map.entry(mid).or_default().push(CastEntry {
|
||||||
name,
|
name,
|
||||||
billing_order: billing_order as u32,
|
billing_order: billing_order as u32,
|
||||||
|
|||||||
@@ -6,4 +6,3 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
rss-feed = { package = "rss", version = "2" }
|
rss-feed = { package = "rss", version = "2" }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
application = { workspace = true }
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use application::ports::RssFeedRenderer;
|
|
||||||
use domain::models::DiaryEntry;
|
use domain::models::DiaryEntry;
|
||||||
|
use domain::ports::RssFeedRenderer;
|
||||||
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
|
||||||
|
|
||||||
pub struct RssAdapter {
|
pub struct RssAdapter {
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
activitypub = { 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 }
|
domain = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
serde_json = { 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 super::*;
|
||||||
|
use k_ap::ActorBlocklist;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
async fn test_pool() -> SqlitePool {
|
async fn test_pool() -> SqlitePool {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
use k_ap::DomainBlocklist;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
async fn test_pool() -> SqlitePool {
|
async fn test_pool() -> SqlitePool {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::ports::SocialQueryPort;
|
use k_ap::AnnounceRepository;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
async fn test_pool() -> SqlitePool {
|
async fn test_pool() -> SqlitePool {
|
||||||
@@ -47,88 +47,3 @@ async fn duplicate_announce_is_ignored() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
uuid = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
uuid = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { 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]
|
#[async_trait]
|
||||||
impl SearchCommand for SqliteSearchAdapter {
|
impl SearchCommand for SqliteSearchAdapter {
|
||||||
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
|
||||||
@@ -86,7 +82,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
|||||||
.bind(&movie_id)
|
.bind(&movie_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
|
"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)
|
.bind(&language)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -118,7 +114,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
|||||||
.bind(&person_id)
|
.bind(&person_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
|
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
|
||||||
@@ -128,7 +124,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
|||||||
.bind(person.known_for_department())
|
.bind(person.known_for_department())
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -144,7 +140,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
EntityType::Person => {
|
EntityType::Person => {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
@@ -153,7 +149,7 @@ impl SearchCommand for SqliteSearchAdapter {
|
|||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -205,7 +201,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(query.filters.year.map(|y| y as i64))
|
.bind(query.filters.year.map(|y| y as i64))
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
} else {
|
} else {
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
@@ -221,7 +217,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(query.filters.year.map(|y| y as i64))
|
.bind(query.filters.year.map(|y| y as i64))
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,7 +245,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as::<_, Row>(
|
sqlx::query_as::<_, Row>(
|
||||||
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
|
||||||
@@ -270,7 +266,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
};
|
};
|
||||||
let items = rows
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -321,7 +317,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(&fts_query)
|
.bind(&fts_query)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
count as u64
|
count as u64
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -346,7 +342,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
let mut items = Vec::with_capacity(rows.len());
|
let mut items = Vec::with_capacity(rows.len());
|
||||||
for row in rows {
|
for row in rows {
|
||||||
@@ -355,7 +351,7 @@ impl SqliteSearchAdapter {
|
|||||||
.bind(&row.person_id)
|
.bind(&row.person_id)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(map_err)?
|
.map_err(adapter_common::map_sqlx_error)?
|
||||||
.flatten();
|
.flatten();
|
||||||
|
|
||||||
let known_for_titles = if let Some(tid) = tmdb_id {
|
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 {
|
pub fn new(pool: SqlitePool) -> Self {
|
||||||
Self { pool }
|
Self { pool }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_err(e: sqlx::Error) -> DomainError {
|
|
||||||
tracing::error!("Database error: {:?}", e);
|
|
||||||
DomainError::InfrastructureError("Database operation failed".into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -36,7 +31,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
|||||||
.bind(&received)
|
.bind(&received)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -53,7 +48,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
|||||||
.bind(ap_id)
|
.bind(ap_id)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -64,7 +59,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -74,7 +69,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -87,7 +82,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
|
|||||||
.bind(actor_url)
|
.bind(actor_url)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(Self::map_err)?;
|
.map_err(adapter_common::map_sqlx_error)?;
|
||||||
|
|
||||||
rows.iter()
|
rows.iter()
|
||||||
.map(|r| {
|
.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