structural refactor and codebase improvements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
5
.github/workflows/ci.yml
vendored
5
.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
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
docs/
|
docs/*
|
||||||
!docs/adr/
|
!docs/adr/
|
||||||
|
|
||||||
imgs/
|
imgs/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
134
Cargo.lock
generated
134
Cargo.lock
generated
@@ -306,6 +306,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"domain",
|
"domain",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"utoipa",
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
@@ -1118,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"
|
||||||
@@ -1621,6 +1655,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3905,7 +3940,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"futures",
|
"futures",
|
||||||
"postgres-federation",
|
"postgres-social",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
@@ -3940,6 +3975,7 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
|
"postgres-social",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3957,6 +3993,18 @@ dependencies = [
|
|||||||
"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"
|
||||||
@@ -3991,43 +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",
|
||||||
"infra-wiring",
|
|
||||||
"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",
|
||||||
@@ -4947,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"
|
||||||
@@ -5190,7 +5261,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlite-federation",
|
"sqlite-social",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5224,6 +5295,7 @@ dependencies = [
|
|||||||
"domain",
|
"domain",
|
||||||
"k-ap",
|
"k-ap",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlite-social",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -5242,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"
|
||||||
@@ -7151,6 +7236,7 @@ dependencies = [
|
|||||||
"application",
|
"application",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"auth",
|
"auth",
|
||||||
|
"composition",
|
||||||
"domain",
|
"domain",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"export",
|
"export",
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -68,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" }
|
||||||
@@ -79,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" }
|
||||||
|
|||||||
16
Dockerfile
16
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,16 +34,20 @@ 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
|
||||||
@@ -71,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
|
||||||
@@ -85,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
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -90,10 +90,11 @@ Hexagonal (Ports & Adapters) with Domain-Driven Design:
|
|||||||
|
|
||||||
```
|
```
|
||||||
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas) + HtmlPageContext; used by presentation, tui, and template adapters
|
||||||
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both presentation and worker binaries
|
infra-wiring — shared infrastructure types (DbPool, EventBusBackend, AppConfig) used by both server and worker binaries
|
||||||
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
|
domain — pure types and CQRS port traits (MovieCommand/MovieQuery, WatchEventCommand/WatchEventQuery, GoalCommand/GoalQuery, DiaryQuery, PersonCommand/PersonQuery, SearchCommand/SearchPort, SocialCommand/SocialQuery, ImageFetcher, RssFeedRenderer), no external deps except serde
|
||||||
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic; modules: auth, diary, goals, import, integrations, movies, person, search, social, users, watchlist, wrapup
|
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, composition root for the HTTP process
|
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
|
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
|
||||||
@@ -159,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
|
||||||
@@ -173,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
|
||||||
|
|
||||||
@@ -249,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 .
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use domain::{
|
|||||||
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
|
||||||
UserFederationSettingsQuery,
|
UserFederationSettingsQuery,
|
||||||
},
|
},
|
||||||
value_objects::{MovieId, ReviewId, UserId},
|
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ pub struct ActivityPubEventHandler {
|
|||||||
goal_repo: Arc<dyn GoalQuery>,
|
goal_repo: Arc<dyn GoalQuery>,
|
||||||
stats_repo: Arc<dyn StatsRepository>,
|
stats_repo: Arc<dyn StatsRepository>,
|
||||||
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
federation_settings: Arc<dyn UserFederationSettingsQuery>,
|
||||||
base_url: String,
|
instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityPubEventHandler {
|
impl ActivityPubEventHandler {
|
||||||
@@ -38,7 +38,7 @@ impl ActivityPubEventHandler {
|
|||||||
goal_repo: Arc<dyn GoalQuery>,
|
goal_repo: Arc<dyn GoalQuery>,
|
||||||
stats_repo: Arc<dyn StatsRepository>,
|
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,
|
||||||
@@ -48,7 +48,7 @@ impl ActivityPubEventHandler {
|
|||||||
goal_repo,
|
goal_repo,
|
||||||
stats_repo,
|
stats_repo,
|
||||||
federation_settings,
|
federation_settings,
|
||||||
base_url,
|
instance,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,7 +140,7 @@ impl EventHandler for ActivityPubEventHandler {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
|
||||||
DomainEvent::UserDeleted { user_id } => {
|
DomainEvent::UserDeleted { user_id } => {
|
||||||
let ap_id = actor_url(&self.base_url, user_id.value());
|
let ap_id = actor_url(&self.instance, user_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
|
||||||
@@ -179,8 +179,8 @@ impl ActivityPubEventHandler {
|
|||||||
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
|
||||||
.movie_repo
|
.movie_repo
|
||||||
@@ -210,8 +210,8 @@ impl ActivityPubEventHandler {
|
|||||||
poster_url: movie
|
poster_url: movie
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.poster_path())
|
.and_then(|m| m.poster_path())
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
|
.map(|p| self.instance.image_url_for(p.value())),
|
||||||
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)?;
|
||||||
@@ -245,8 +245,8 @@ impl ActivityPubEventHandler {
|
|||||||
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
|
||||||
.movie_repo
|
.movie_repo
|
||||||
@@ -276,8 +276,8 @@ impl ActivityPubEventHandler {
|
|||||||
poster_url: movie
|
poster_url: movie
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|m| m.poster_path())
|
.and_then(|m| m.poster_path())
|
||||||
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
|
.map(|p| self.instance.image_url_for(p.value())),
|
||||||
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)?;
|
||||||
@@ -294,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?;
|
||||||
@@ -320,8 +320,8 @@ impl ActivityPubEventHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
.movie_repo
|
.movie_repo
|
||||||
@@ -331,7 +331,7 @@ impl ActivityPubEventHandler {
|
|||||||
.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 =
|
||||||
@@ -344,7 +344,7 @@ 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)?;
|
||||||
|
|
||||||
@@ -360,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?;
|
||||||
@@ -383,7 +383,7 @@ impl ActivityPubEventHandler {
|
|||||||
.map(|id| id.value().to_string());
|
.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();
|
||||||
@@ -398,8 +398,8 @@ impl ActivityPubEventHandler {
|
|||||||
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,
|
||||||
@@ -410,7 +410,7 @@ impl ActivityPubEventHandler {
|
|||||||
release_year: movie.release_year().value(),
|
release_year: movie.release_year().value(),
|
||||||
external_metadata_id: external_metadata_id.clone(),
|
external_metadata_id: external_metadata_id.clone(),
|
||||||
poster_url: poster_url.clone(),
|
poster_url: poster_url.clone(),
|
||||||
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)?;
|
||||||
@@ -450,15 +450,15 @@ impl ActivityPubEventHandler {
|
|||||||
.count_reviews_in_year(user_id, year)
|
.count_reviews_in_year(user_id, year)
|
||||||
.await
|
.await
|
||||||
.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(
|
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
|
||||||
@@ -488,9 +488,16 @@ impl ActivityPubEventHandler {
|
|||||||
.await
|
.await
|
||||||
.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
|
||||||
@@ -513,7 +520,7 @@ impl ActivityPubEventHandler {
|
|||||||
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?;
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ use chrono::DateTime;
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::RemoteGoalEntry,
|
models::RemoteGoalEntry,
|
||||||
ports::{GoalQuery, RemoteGoalRepository},
|
ports::{GoalQuery, RemoteGoalRepository},
|
||||||
value_objects::UserId,
|
value_objects::{InstanceIdentity, UserId},
|
||||||
};
|
};
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -16,7 +16,7 @@ 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 goal_repo: Arc<dyn GoalQuery>,
|
||||||
pub base_url: String,
|
pub instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -34,11 +34,11 @@ impl ApContentReader for GoalObjectHandler {
|
|||||||
.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 follower_cc = format!("{}/followers", actor);
|
let follower_cc = format!("{}/followers", actor);
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for goal in goals {
|
for goal in goals {
|
||||||
let ap_id = goal_url(&self.base_url, user_id, goal.year());
|
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 published = DateTime::from_naive_utc_and_offset(*goal.created_at(), chrono::Utc);
|
||||||
let obj = goal_to_ap_object(
|
let obj = goal_to_ap_object(
|
||||||
ap_id.clone(),
|
ap_id.clone(),
|
||||||
@@ -46,7 +46,7 @@ impl ApContentReader for GoalObjectHandler {
|
|||||||
goal.year(),
|
goal.year(),
|
||||||
goal.target_count(),
|
goal.target_count(),
|
||||||
0,
|
0,
|
||||||
&self.base_url,
|
self.instance.base_url(),
|
||||||
);
|
);
|
||||||
results.push(LocalObject {
|
results.push(LocalObject {
|
||||||
ap_id,
|
ap_id,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
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 mod social_adapter;
|
||||||
@@ -22,7 +22,7 @@ pub use k_ap::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
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, RemoteReviewUpdate};
|
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
|
||||||
pub use review_handler::ReviewObjectHandler;
|
pub use review_handler::ReviewObjectHandler;
|
||||||
pub use social_adapter::CompositeSocialAdapter;
|
pub use social_adapter::CompositeSocialAdapter;
|
||||||
@@ -41,7 +41,16 @@ pub struct FederationRepos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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>,
|
||||||
}
|
}
|
||||||
@@ -64,7 +73,7 @@ pub struct ActivityPubDeps {
|
|||||||
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
|
||||||
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
|
||||||
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
|
||||||
pub base_url: String,
|
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>,
|
||||||
}
|
}
|
||||||
@@ -88,7 +97,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
federation_settings,
|
federation_settings,
|
||||||
follow_command: _,
|
follow_command: _,
|
||||||
follow_query: _,
|
follow_query: _,
|
||||||
base_url,
|
instance,
|
||||||
allow_registration,
|
allow_registration,
|
||||||
event_publisher,
|
event_publisher,
|
||||||
} = deps;
|
} = deps;
|
||||||
@@ -98,17 +107,17 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
diary_repo,
|
diary_repo,
|
||||||
review_store,
|
review_store,
|
||||||
event_publisher: std::sync::Arc::clone(&event_publisher),
|
event_publisher: std::sync::Arc::clone(&event_publisher),
|
||||||
base_url: base_url.clone(),
|
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 {
|
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
|
||||||
remote_goal_repo,
|
remote_goal_repo,
|
||||||
goal_repo: std::sync::Arc::clone(&goal_repo),
|
goal_repo: std::sync::Arc::clone(&goal_repo),
|
||||||
base_url: base_url.clone(),
|
instance: instance.clone(),
|
||||||
});
|
});
|
||||||
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
|
||||||
review: review_handler,
|
review: review_handler,
|
||||||
@@ -132,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>)
|
||||||
@@ -165,11 +174,20 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
|
|||||||
goal_repo,
|
goal_repo,
|
||||||
stats_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,178 +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 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 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 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 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 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 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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,9 @@ use domain::{
|
|||||||
events::DomainEvent,
|
events::DomainEvent,
|
||||||
models::ReviewSource,
|
models::ReviewSource,
|
||||||
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
|
||||||
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
|
value_objects::{
|
||||||
|
Comment, ExternalMetadataId, InstanceIdentity, MovieId, Rating, ReviewId, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -20,7 +22,7 @@ pub struct ReviewObjectHandler {
|
|||||||
pub diary_repo: Arc<dyn DiaryQuery>,
|
pub diary_repo: Arc<dyn DiaryQuery>,
|
||||||
pub review_store: Arc<dyn RemoteReviewRepository>,
|
pub review_store: Arc<dyn RemoteReviewRepository>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
pub base_url: String,
|
pub instance: InstanceIdentity,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -39,17 +41,17 @@ 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,
|
||||||
@@ -62,7 +64,7 @@ impl ApContentReader for ReviewObjectHandler {
|
|||||||
.external_metadata_id()
|
.external_metadata_id()
|
||||||
.map(|id| id.value().to_string()),
|
.map(|id| id.value().to_string()),
|
||||||
poster_url,
|
poster_url,
|
||||||
base_url: self.base_url.clone(),
|
base_url: self.instance.base_url().to_string(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let follower_cc = format!("{}/followers", actor);
|
let follower_cc = format!("{}/followers", actor);
|
||||||
|
|||||||
@@ -3,73 +3,33 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
|
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
|
||||||
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
|
value_objects::{
|
||||||
|
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::ActivityPubPort;
|
use k_ap::ActivityPubService;
|
||||||
|
|
||||||
pub struct CompositeSocialAdapter {
|
pub struct CompositeSocialAdapter {
|
||||||
ap_service: Arc<dyn ActivityPubPort>,
|
local: Arc<dyn LocalSocial>,
|
||||||
|
ap_service: Arc<ActivityPubService>,
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
follow_command: Arc<dyn FollowCommand>,
|
instance: InstanceIdentity,
|
||||||
follow_query: Arc<dyn FollowQuery>,
|
|
||||||
base_url: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompositeSocialAdapter {
|
impl CompositeSocialAdapter {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
ap_service: Arc<dyn ActivityPubPort>,
|
local: Arc<dyn LocalSocial>,
|
||||||
|
ap_service: Arc<ActivityPubService>,
|
||||||
user_repo: Arc<dyn UserRepository>,
|
user_repo: Arc<dyn UserRepository>,
|
||||||
follow_command: Arc<dyn FollowCommand>,
|
instance: InstanceIdentity,
|
||||||
follow_query: Arc<dyn FollowQuery>,
|
|
||||||
base_url: String,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
local,
|
||||||
ap_service,
|
ap_service,
|
||||||
user_repo,
|
user_repo,
|
||||||
follow_command,
|
instance,
|
||||||
follow_query,
|
|
||||||
base_url,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn local_actor_url(&self, user_id: &UserId) -> String {
|
|
||||||
format!("{}/users/{}", self.base_url, user_id.value())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn actor_url_from_identity(&self, identity: &SocialIdentity) -> String {
|
|
||||||
match identity {
|
|
||||||
SocialIdentity::Local(uid) => self.local_actor_url(uid),
|
|
||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn resolve_target_identity(
|
|
||||||
&self,
|
|
||||||
target: &FollowTarget,
|
|
||||||
) -> Result<SocialIdentity, DomainError> {
|
|
||||||
match target {
|
|
||||||
FollowTarget::Identity(id) => Ok(id.clone()),
|
|
||||||
FollowTarget::Handle(handle) => {
|
|
||||||
let host = handle.rsplit_once('@').map(|(_, h)| h).unwrap_or("");
|
|
||||||
let local_host = SocialIdentity::host_from_base_url(&self.base_url);
|
|
||||||
if host == local_host {
|
|
||||||
let username_str = handle
|
|
||||||
.trim_start_matches('@')
|
|
||||||
.split('@')
|
|
||||||
.next()
|
|
||||||
.unwrap_or("");
|
|
||||||
if let Ok(username) = Username::new(username_str.to_string())
|
|
||||||
&& let Some(user) = self.user_repo.find_by_username(&username).await?
|
|
||||||
{
|
|
||||||
return Ok(SocialIdentity::Local(user.id().clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(SocialIdentity::Remote {
|
|
||||||
actor_url: handle.clone(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,23 +41,10 @@ fn ap_err(e: anyhow::Error) -> DomainError {
|
|||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SocialCommand for CompositeSocialAdapter {
|
impl SocialCommand for CompositeSocialAdapter {
|
||||||
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
|
||||||
let identity = self.resolve_target_identity(target).await?;
|
let identity = self.local.resolve_target(target).await?;
|
||||||
|
|
||||||
if let SocialIdentity::Local(ref target_id) = identity {
|
if let SocialIdentity::Local(_) = identity {
|
||||||
if follower == target_id {
|
return self.local.follow_resolved(follower, &identity).await;
|
||||||
return Err(DomainError::ValidationError(
|
|
||||||
"Cannot follow yourself".into(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let follower_url = self.local_actor_url(follower);
|
|
||||||
let target_url = self.local_actor_url(target_id);
|
|
||||||
self.follow_command
|
|
||||||
.add_follower(target_id.value(), &follower_url, FollowStatus::Pending)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.add_follow(follower.value(), &target_url, FollowStatus::Pending)
|
|
||||||
.await?;
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let handle = match target {
|
let handle = match target {
|
||||||
@@ -109,7 +56,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
.find_by_id(uid)
|
.find_by_id(uid)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||||
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
|
self.instance.handle_for(user.username().value())
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
SocialIdentity::Remote { actor_url } => actor_url.clone(),
|
||||||
},
|
},
|
||||||
@@ -125,21 +72,11 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
follower: &UserId,
|
follower: &UserId,
|
||||||
target: &SocialIdentity,
|
target: &SocialIdentity,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
match target {
|
match target {
|
||||||
SocialIdentity::Local(target_id) => {
|
SocialIdentity::Local(_) => self.local.unfollow(follower, target).await,
|
||||||
let follower_url = self.local_actor_url(follower);
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(follower.value(), &actor_url)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follower_record(target_id.value(), &follower_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
SocialIdentity::Remote { .. } => self
|
||||||
.ap_service
|
.ap_service
|
||||||
.unfollow(follower.value(), &actor_url)
|
.unfollow(follower.value(), &self.instance.actor_url_of(target))
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err),
|
.map_err(ap_err),
|
||||||
}
|
}
|
||||||
@@ -150,21 +87,11 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(requester);
|
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(requester_id) => {
|
SocialIdentity::Local(_) => self.local.accept_follow(owner, requester).await,
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Accepted)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.update_follow_status(requester_id.value(), &owner_url, FollowStatus::Accepted)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
SocialIdentity::Remote { .. } => self
|
||||||
.ap_service
|
.ap_service
|
||||||
.accept_follower(owner.value(), &actor_url)
|
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err),
|
.map_err(ap_err),
|
||||||
}
|
}
|
||||||
@@ -175,21 +102,11 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
requester: &SocialIdentity,
|
requester: &SocialIdentity,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(requester);
|
|
||||||
match requester {
|
match requester {
|
||||||
SocialIdentity::Local(requester_id) => {
|
SocialIdentity::Local(_) => self.local.reject_follow(owner, requester).await,
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.update_follower_status(owner.value(), &actor_url, FollowStatus::Rejected)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(requester_id.value(), &owner_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
SocialIdentity::Remote { .. } => self
|
||||||
.ap_service
|
.ap_service
|
||||||
.reject_follower(owner.value(), &actor_url)
|
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err),
|
.map_err(ap_err),
|
||||||
}
|
}
|
||||||
@@ -200,28 +117,18 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
owner: &UserId,
|
owner: &UserId,
|
||||||
follower: &SocialIdentity,
|
follower: &SocialIdentity,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(follower);
|
|
||||||
match follower {
|
match follower {
|
||||||
SocialIdentity::Local(follower_id) => {
|
SocialIdentity::Local(_) => self.local.remove_follower(owner, follower).await,
|
||||||
let owner_url = self.local_actor_url(owner);
|
|
||||||
self.follow_command
|
|
||||||
.remove_follower_record(owner.value(), &actor_url)
|
|
||||||
.await?;
|
|
||||||
self.follow_command
|
|
||||||
.remove_follow(follower_id.value(), &owner_url)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
SocialIdentity::Remote { .. } => self
|
SocialIdentity::Remote { .. } => self
|
||||||
.ap_service
|
.ap_service
|
||||||
.remove_follower(owner.value(), &actor_url)
|
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
|
||||||
.await
|
.await
|
||||||
.map_err(ap_err),
|
.map_err(ap_err),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
let actor_url = self.instance.actor_url_of(target);
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.block_actor(blocker.value(), &actor_url)
|
.block_actor(blocker.value(), &actor_url)
|
||||||
.await
|
.await
|
||||||
@@ -229,7 +136,7 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
let actor_url = self.instance.actor_url_of(target);
|
||||||
self.ap_service
|
self.ap_service
|
||||||
.unblock_actor(blocker.value(), &actor_url)
|
.unblock_actor(blocker.value(), &actor_url)
|
||||||
.await
|
.await
|
||||||
@@ -238,33 +145,46 @@ impl SocialCommand for CompositeSocialAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl SocialQuery for CompositeSocialAdapter {
|
impl FollowGraphQuery for CompositeSocialAdapter {
|
||||||
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
self.follow_query
|
self.local.get_following(user).await
|
||||||
.get_following(user.value(), &self.base_url)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
self.follow_query
|
self.local.get_followers(user).await
|
||||||
.get_followers(user.value(), &self.base_url)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
self.follow_query
|
self.local.get_pending_followers(user).await
|
||||||
.get_pending_followers(user.value(), &self.base_url)
|
}
|
||||||
.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> {
|
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||||
self.follow_query.count_following(user.value()).await
|
self.local.count_following(user).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
|
||||||
self.follow_query.count_followers(user.value()).await
|
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> {
|
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
let actors = self
|
let actors = self
|
||||||
.ap_service
|
.ap_service
|
||||||
@@ -274,7 +194,7 @@ impl SocialQuery for CompositeSocialAdapter {
|
|||||||
Ok(actors
|
Ok(actors
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|a| {
|
.map(|a| {
|
||||||
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
|
let identity = self.instance.identify(&a.url);
|
||||||
SocialActor {
|
SocialActor {
|
||||||
identity,
|
identity,
|
||||||
handle: a.handle,
|
handle: a.handle,
|
||||||
@@ -284,15 +204,4 @@ impl SocialQuery for CompositeSocialAdapter {
|
|||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_following(
|
|
||||||
&self,
|
|
||||||
follower: &UserId,
|
|
||||||
target: &SocialIdentity,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
let actor_url = self.actor_url_from_identity(target);
|
|
||||||
self.follow_query
|
|
||||||
.is_following(follower.value(), &actor_url)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use chrono::DateTime;
|
|||||||
use domain::{
|
use domain::{
|
||||||
models::{RemoteWatchlistEntry, WatchlistWithMovie},
|
models::{RemoteWatchlistEntry, WatchlistWithMovie},
|
||||||
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
ports::{LocalApContentQuery, RemoteWatchlistRepository},
|
||||||
value_objects::UserId,
|
value_objects::{InstanceIdentity, UserId},
|
||||||
};
|
};
|
||||||
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -16,7 +16,7 @@ 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]
|
#[async_trait]
|
||||||
@@ -34,15 +34,15 @@ impl ApContentReader for WatchlistObjectHandler {
|
|||||||
.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 follower_cc = format!("{}/followers", actor);
|
let follower_cc = format!("{}/followers", actor);
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
for WatchlistWithMovie { entry, movie } in entries {
|
for WatchlistWithMovie { entry, movie } in entries {
|
||||||
let ap_id = watchlist_entry_url(&self.base_url, user_id, entry.movie_id.value());
|
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 published = DateTime::from_naive_utc_and_offset(entry.added_at, chrono::Utc);
|
||||||
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 = watchlist_to_ap_object(WatchlistApInput {
|
let obj = watchlist_to_ap_object(WatchlistApInput {
|
||||||
ap_id: ap_id.clone(),
|
ap_id: ap_id.clone(),
|
||||||
actor_url: actor.clone(),
|
actor_url: actor.clone(),
|
||||||
@@ -53,7 +53,7 @@ impl ApContentReader for WatchlistObjectHandler {
|
|||||||
.map(|id| id.value().to_string()),
|
.map(|id| id.value().to_string()),
|
||||||
poster_url,
|
poster_url,
|
||||||
added_at: published,
|
added_at: published,
|
||||||
base_url: self.base_url.clone(),
|
base_url: self.instance.base_url().to_string(),
|
||||||
});
|
});
|
||||||
results.push(LocalObject {
|
results.push(LocalObject {
|
||||||
ap_id,
|
ap_id,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
adapter-common = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
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 }
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
mod activity;
|
mod activity;
|
||||||
mod actor;
|
mod actor;
|
||||||
pub mod ap_content;
|
|
||||||
mod blocklist;
|
mod blocklist;
|
||||||
mod federated_profile;
|
|
||||||
mod follow;
|
mod follow;
|
||||||
mod follow_repository;
|
|
||||||
pub mod remote_goals;
|
|
||||||
mod review;
|
mod review;
|
||||||
mod social;
|
|
||||||
mod watchlist;
|
|
||||||
|
|
||||||
pub use ap_content::PostgresApContentQuery;
|
|
||||||
pub use remote_goals::PostgresRemoteGoalRepository;
|
|
||||||
|
|
||||||
use k_ap::{FollowerStatus, RemoteActor};
|
use k_ap::{FollowerStatus, RemoteActor};
|
||||||
use sqlx::{PgPool, Row};
|
use sqlx::{PgPool, Row};
|
||||||
@@ -72,23 +63,23 @@ impl PostgresFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_federated_profile_query(
|
pub fn wire(
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
instance: domain::value_objects::InstanceIdentity,
|
||||||
std::sync::Arc::new(PostgresFederationRepository::new(pool))
|
) -> activitypub::FederationRepos {
|
||||||
}
|
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone()));
|
||||||
|
let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new(
|
||||||
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
|
pool, instance,
|
||||||
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
|
));
|
||||||
activitypub::FederationRepos {
|
activitypub::FederationRepos {
|
||||||
activity: std::sync::Arc::clone(&fed) as _,
|
activity: std::sync::Arc::clone(&fed) as _,
|
||||||
follow: std::sync::Arc::clone(&fed) as _,
|
follow: std::sync::Arc::clone(&fed) as _,
|
||||||
actor: std::sync::Arc::clone(&fed) as _,
|
actor: std::sync::Arc::clone(&fed) as _,
|
||||||
blocklist: std::sync::Arc::clone(&fed) as _,
|
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||||
admin_query: std::sync::Arc::clone(&fed) as _,
|
review_store: fed as _,
|
||||||
review_store: std::sync::Arc::clone(&fed) as _,
|
admin_query: std::sync::Arc::clone(&social) as _,
|
||||||
remote_watchlist: std::sync::Arc::clone(&fed) as _,
|
remote_watchlist: std::sync::Arc::clone(&social) as _,
|
||||||
follow_command: std::sync::Arc::clone(&fed) as _,
|
follow_command: std::sync::Arc::clone(&social) as _,
|
||||||
follow_query: fed as _,
|
follow_query: social as _,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 }
|
||||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
|||||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use super::PostgresFederationRepository;
|
use super::PostgresSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederatedProfileQuery for PostgresFederationRepository {
|
impl FederatedProfileQuery for PostgresSocialRepository {
|
||||||
async fn get_federated_profile(
|
async fn get_federated_profile(
|
||||||
&self,
|
&self,
|
||||||
synthetic_user_id: uuid::Uuid,
|
synthetic_user_id: uuid::Uuid,
|
||||||
@@ -2,11 +2,11 @@ use async_trait::async_trait;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{FollowStatus, SocialActor, SocialIdentity},
|
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use crate::PostgresFederationRepository;
|
use crate::PostgresSocialRepository;
|
||||||
use adapter_common::datetime_to_str;
|
use adapter_common::datetime_to_str;
|
||||||
|
|
||||||
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||||
@@ -21,8 +21,17 @@ fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
|||||||
DomainError::InfrastructureError(e.to_string())
|
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]
|
#[async_trait]
|
||||||
impl domain::ports::FollowCommand for PostgresFederationRepository {
|
impl domain::ports::FollowCommand for PostgresSocialRepository {
|
||||||
async fn add_follow(
|
async fn add_follow(
|
||||||
&self,
|
&self,
|
||||||
follower_id: uuid::Uuid,
|
follower_id: uuid::Uuid,
|
||||||
@@ -142,9 +151,9 @@ impl domain::ports::FollowCommand for PostgresFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialActor {
|
fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor {
|
||||||
let actor_url: String = row.get("remote_actor_url");
|
let actor_url: String = row.get("remote_actor_url");
|
||||||
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
|
let identity = instance.identify(&actor_url);
|
||||||
|
|
||||||
let (handle, display_name, avatar_url) = match &identity {
|
let (handle, display_name, avatar_url) = match &identity {
|
||||||
SocialIdentity::Local(_) => {
|
SocialIdentity::Local(_) => {
|
||||||
@@ -154,17 +163,18 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA
|
|||||||
.try_get::<Option<String>, _>("local_avatar_path")
|
.try_get::<Option<String>, _>("local_avatar_path")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|p| format!("{}/images/{}", base_url, p));
|
.map(|p| instance.image_url_for(&p));
|
||||||
let handle = username
|
let handle = username
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|u| SocialIdentity::format_local_handle(u, base_url))
|
.map(|u| instance.handle_for(u))
|
||||||
.unwrap_or_else(|| actor_url.clone());
|
.unwrap_or_else(|| actor_url.clone());
|
||||||
(handle, display, avatar)
|
(handle, display, avatar)
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
let handle: String = row
|
let handle: String = row
|
||||||
.try_get("remote_handle")
|
.try_get::<Option<String>, _>("remote_handle")
|
||||||
.ok()
|
.ok()
|
||||||
|
.flatten()
|
||||||
.unwrap_or_else(|| actor_url.clone());
|
.unwrap_or_else(|| actor_url.clone());
|
||||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||||
@@ -181,12 +191,8 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl domain::ports::FollowQuery for PostgresFederationRepository {
|
impl domain::ports::FollowQuery for PostgresSocialRepository {
|
||||||
async fn get_following(
|
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
@@ -197,22 +203,18 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.map(|r| social_actor_from_row(r, &self.instance))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_followers(
|
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
@@ -223,21 +225,20 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.map(|r| social_actor_from_row(r, &self.instance))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -249,14 +250,39 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = $2 AND f.status = 'pending'",
|
WHERE f.local_user_id = $2 AND f.status = 'pending'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.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())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,20 +310,45 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
|
|||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_following(
|
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
&self,
|
let uid = user_id.to_string();
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
let uid = follower_id.to_string();
|
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2 AND status = 'accepted'",
|
"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(&uid)
|
||||||
.bind(target_actor_url)
|
.bind(target_actor_url)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(count > 0)
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||||
|
|
||||||
use super::PostgresFederationRepository;
|
use super::PostgresSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationAdminQuery for PostgresFederationRepository {
|
impl FederationAdminQuery for PostgresSocialRepository {
|
||||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
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'",
|
"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'",
|
||||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
|||||||
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use super::PostgresFederationRepository;
|
use super::PostgresSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RemoteWatchlistRepository for PostgresFederationRepository {
|
impl RemoteWatchlistRepository for PostgresSocialRepository {
|
||||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_remote_watchlist_entries \
|
"INSERT INTO ap_remote_watchlist_entries \
|
||||||
@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
] }
|
] }
|
||||||
adapter-common = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
postgres-federation = { workspace = true }
|
postgres-social = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ pub use import_session::PostgresImportSessionRepository;
|
|||||||
pub use movie::PostgresMovieRepository;
|
pub use movie::PostgresMovieRepository;
|
||||||
pub use movie_dedup::PostgresMovieDeduplicator;
|
pub use movie_dedup::PostgresMovieDeduplicator;
|
||||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||||
pub use postgres_federation::PostgresApContentQuery;
|
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;
|
||||||
@@ -115,7 +115,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
|
|||||||
goal_query: 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(postgres_federation::PostgresRemoteGoalRepository::new(
|
remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
)) as _,
|
)) as _,
|
||||||
deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _,
|
deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _,
|
||||||
|
|||||||
@@ -192,7 +192,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
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();
|
||||||
|
|
||||||
@@ -210,31 +213,40 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
|
|||||||
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,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ sqlx = { workspace = true }
|
|||||||
activitypub = { workspace = true }
|
activitypub = { workspace = true }
|
||||||
adapter-common = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
k-ap = { version = "0.5.0", registry = "gitea" }
|
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 }
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
mod activity;
|
mod activity;
|
||||||
mod actor;
|
mod actor;
|
||||||
mod blocklist;
|
mod blocklist;
|
||||||
mod federated_profile;
|
|
||||||
mod follow;
|
mod follow;
|
||||||
mod follow_repository;
|
|
||||||
mod review;
|
mod review;
|
||||||
mod social;
|
|
||||||
mod watchlist;
|
|
||||||
|
|
||||||
pub mod ap_content;
|
|
||||||
pub mod remote_goals;
|
|
||||||
|
|
||||||
pub use ap_content::SqliteApContentQuery;
|
|
||||||
pub use remote_goals::SqliteRemoteGoalRepository;
|
|
||||||
|
|
||||||
use k_ap::{FollowerStatus, RemoteActor};
|
use k_ap::{FollowerStatus, RemoteActor};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
@@ -84,24 +74,22 @@ impl SqliteFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_federated_profile_query(
|
pub fn wire(
|
||||||
pool: SqlitePool,
|
pool: SqlitePool,
|
||||||
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
|
instance: domain::value_objects::InstanceIdentity,
|
||||||
std::sync::Arc::new(SqliteFederationRepository::new(pool))
|
) -> activitypub::FederationRepos {
|
||||||
}
|
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool.clone()));
|
||||||
|
let social = std::sync::Arc::new(sqlite_social::SqliteSocialRepository::new(pool, instance));
|
||||||
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
|
|
||||||
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
|
|
||||||
activitypub::FederationRepos {
|
activitypub::FederationRepos {
|
||||||
activity: std::sync::Arc::clone(&fed) as _,
|
activity: std::sync::Arc::clone(&fed) as _,
|
||||||
follow: std::sync::Arc::clone(&fed) as _,
|
follow: std::sync::Arc::clone(&fed) as _,
|
||||||
actor: std::sync::Arc::clone(&fed) as _,
|
actor: std::sync::Arc::clone(&fed) as _,
|
||||||
blocklist: std::sync::Arc::clone(&fed) as _,
|
blocklist: std::sync::Arc::clone(&fed) as _,
|
||||||
admin_query: std::sync::Arc::clone(&fed) as _,
|
review_store: fed as _,
|
||||||
review_store: std::sync::Arc::clone(&fed) as _,
|
admin_query: std::sync::Arc::clone(&social) as _,
|
||||||
remote_watchlist: std::sync::Arc::clone(&fed) as _,
|
remote_watchlist: std::sync::Arc::clone(&social) as _,
|
||||||
follow_command: std::sync::Arc::clone(&fed) as _,
|
follow_command: std::sync::Arc::clone(&social) as _,
|
||||||
follow_query: fed as _,
|
follow_query: social as _,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::ports::FederationAdminQuery;
|
|
||||||
use k_ap::AnnounceRepository;
|
use k_ap::AnnounceRepository;
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
@@ -48,65 +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_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");
|
|
||||||
}
|
|
||||||
|
|||||||
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 }
|
||||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
|||||||
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use super::SqliteFederationRepository;
|
use super::SqliteSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederatedProfileQuery for SqliteFederationRepository {
|
impl FederatedProfileQuery for SqliteSocialRepository {
|
||||||
async fn get_federated_profile(
|
async fn get_federated_profile(
|
||||||
&self,
|
&self,
|
||||||
synthetic_user_id: uuid::Uuid,
|
synthetic_user_id: uuid::Uuid,
|
||||||
@@ -2,11 +2,11 @@ use async_trait::async_trait;
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
value_objects::{FollowStatus, SocialActor, SocialIdentity},
|
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
|
||||||
};
|
};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use crate::SqliteFederationRepository;
|
use crate::SqliteSocialRepository;
|
||||||
use adapter_common::datetime_to_str;
|
use adapter_common::datetime_to_str;
|
||||||
|
|
||||||
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
|
||||||
@@ -21,8 +21,17 @@ fn infra_err(e: impl std::fmt::Display) -> DomainError {
|
|||||||
DomainError::InfrastructureError(e.to_string())
|
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]
|
#[async_trait]
|
||||||
impl domain::ports::FollowCommand for SqliteFederationRepository {
|
impl domain::ports::FollowCommand for SqliteSocialRepository {
|
||||||
async fn add_follow(
|
async fn add_follow(
|
||||||
&self,
|
&self,
|
||||||
follower_id: uuid::Uuid,
|
follower_id: uuid::Uuid,
|
||||||
@@ -142,9 +151,12 @@ impl domain::ports::FollowCommand for SqliteFederationRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> SocialActor {
|
fn social_actor_from_row(
|
||||||
|
row: &sqlx::sqlite::SqliteRow,
|
||||||
|
instance: &InstanceIdentity,
|
||||||
|
) -> SocialActor {
|
||||||
let actor_url: String = row.get("remote_actor_url");
|
let actor_url: String = row.get("remote_actor_url");
|
||||||
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
|
let identity = instance.identify(&actor_url);
|
||||||
|
|
||||||
let (handle, display_name, avatar_url) = match &identity {
|
let (handle, display_name, avatar_url) = match &identity {
|
||||||
SocialIdentity::Local(_) => {
|
SocialIdentity::Local(_) => {
|
||||||
@@ -154,17 +166,18 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia
|
|||||||
.try_get::<Option<String>, _>("local_avatar_path")
|
.try_get::<Option<String>, _>("local_avatar_path")
|
||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|p| format!("{}/images/{}", base_url, p));
|
.map(|p| instance.image_url_for(&p));
|
||||||
let handle = username
|
let handle = username
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|u| SocialIdentity::format_local_handle(u, base_url))
|
.map(|u| instance.handle_for(u))
|
||||||
.unwrap_or_else(|| actor_url.clone());
|
.unwrap_or_else(|| actor_url.clone());
|
||||||
(handle, display, avatar)
|
(handle, display, avatar)
|
||||||
}
|
}
|
||||||
SocialIdentity::Remote { .. } => {
|
SocialIdentity::Remote { .. } => {
|
||||||
let handle: String = row
|
let handle: String = row
|
||||||
.try_get("remote_handle")
|
.try_get::<Option<String>, _>("remote_handle")
|
||||||
.ok()
|
.ok()
|
||||||
|
.flatten()
|
||||||
.unwrap_or_else(|| actor_url.clone());
|
.unwrap_or_else(|| actor_url.clone());
|
||||||
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
let display: Option<String> = row.try_get("remote_display").ok().flatten();
|
||||||
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
|
||||||
@@ -181,12 +194,8 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl domain::ports::FollowQuery for SqliteFederationRepository {
|
impl domain::ports::FollowQuery for SqliteSocialRepository {
|
||||||
async fn get_following(
|
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
@@ -197,22 +206,18 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.map(|r| social_actor_from_row(r, &self.instance))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_followers(
|
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
&self,
|
|
||||||
user_id: uuid::Uuid,
|
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT f.remote_actor_url,
|
"SELECT f.remote_actor_url,
|
||||||
@@ -223,21 +228,20 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.map(|r| social_actor_from_row(r, &self.instance))
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_pending_followers(
|
async fn get_pending_followers(
|
||||||
&self,
|
&self,
|
||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
base_url: &str,
|
|
||||||
) -> Result<Vec<SocialActor>, DomainError> {
|
) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
let uid = user_id.to_string();
|
let uid = user_id.to_string();
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
@@ -249,14 +253,39 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
|
|||||||
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
|
||||||
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
|
||||||
)
|
)
|
||||||
.bind(base_url)
|
.bind(self.instance.base_url())
|
||||||
.bind(&uid)
|
.bind(&uid)
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| social_actor_from_row(r, base_url))
|
.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())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,20 +313,45 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
|
|||||||
Ok(count as usize)
|
Ok(count as usize)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_following(
|
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
|
||||||
&self,
|
let uid = user_id.to_string();
|
||||||
follower_id: uuid::Uuid,
|
|
||||||
target_actor_url: &str,
|
|
||||||
) -> Result<bool, DomainError> {
|
|
||||||
let uid = follower_id.to_string();
|
|
||||||
let count: i64 = sqlx::query_scalar(
|
let count: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ? AND status = 'accepted'",
|
"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(&uid)
|
||||||
.bind(target_actor_url)
|
.bind(target_actor_url)
|
||||||
.fetch_one(&self.pool)
|
.fetch_one(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(infra_err)?;
|
.map_err(infra_err)?;
|
||||||
Ok(count > 0)
|
|
||||||
|
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;
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
|
||||||
|
|
||||||
use super::SqliteFederationRepository;
|
use super::SqliteSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FederationAdminQuery for SqliteFederationRepository {
|
impl FederationAdminQuery for SqliteSocialRepository {
|
||||||
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
|
||||||
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
|
||||||
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
"SELECT DISTINCT ar.url, ar.handle, ar.display_name
|
||||||
253
crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs
Normal file
253
crates/adapters/sqlite-social/src/tests/follow_relation_tests.rs
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
use super::*;
|
||||||
|
use domain::ports::{FederationAdminQuery, FollowQuery};
|
||||||
|
use domain::value_objects::{FollowStatus, InstanceIdentity, SocialIdentity};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
async fn test_pool() -> SqlitePool {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
for ddl in [
|
||||||
|
"CREATE TABLE ap_following (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||||
|
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||||
|
"CREATE TABLE ap_followers (local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
|
||||||
|
follow_activity_id TEXT NOT NULL, created_at TEXT, status TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (local_user_id, remote_actor_url))",
|
||||||
|
"CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
|
||||||
|
display_name TEXT, avatar_path TEXT)",
|
||||||
|
"CREATE TABLE ap_remote_actors (url TEXT PRIMARY KEY, handle TEXT NOT NULL,
|
||||||
|
display_name TEXT, avatar_url TEXT)",
|
||||||
|
] {
|
||||||
|
sqlx::query(ddl).execute(&pool).await.unwrap();
|
||||||
|
}
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
fn repo(pool: SqlitePool) -> SqliteSocialRepository {
|
||||||
|
SqliteSocialRepository::new(pool, InstanceIdentity::new("https://md.example"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_relation_returns_no_edges_for_strangers() {
|
||||||
|
let r = repo(test_pool().await);
|
||||||
|
let rel = r
|
||||||
|
.get_relation(uuid::Uuid::new_v4(), "https://other.example/users/bob")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rel.following, None);
|
||||||
|
assert_eq!(rel.followed_by, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_relation_reads_following_direction_only_from_ap_following() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let viewer = uuid::Uuid::new_v4();
|
||||||
|
let target = "https://other.example/users/bob";
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, ?2, '', 'pending')",
|
||||||
|
)
|
||||||
|
.bind(viewer.to_string())
|
||||||
|
.bind(target)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rel.following, Some(FollowStatus::Pending));
|
||||||
|
assert_eq!(
|
||||||
|
rel.followed_by, None,
|
||||||
|
"an ap_following row must not populate followed_by"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_relation_reads_followed_by_from_ap_followers_including_rejected() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let owner = uuid::Uuid::new_v4();
|
||||||
|
let requester = "https://other.example/users/carol";
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, ?2, '', 'rejected')",
|
||||||
|
)
|
||||||
|
.bind(owner.to_string())
|
||||||
|
.bind(requester)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rel = repo(pool).get_relation(owner, requester).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rel.followed_by, Some(FollowStatus::Rejected));
|
||||||
|
assert_eq!(rel.following, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_relation_treats_unknown_status_as_no_edge() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let viewer = uuid::Uuid::new_v4();
|
||||||
|
let target = "https://other.example/users/dave";
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, ?2, '', 'not-a-real-status')",
|
||||||
|
)
|
||||||
|
.bind(viewer.to_string())
|
||||||
|
.bind(target)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let rel = repo(pool).get_relation(viewer, target).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(rel.following, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_pending_following_returns_only_pending_rows() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let viewer = uuid::Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, 'https://other.example/users/pending', '', 'pending'),
|
||||||
|
(?1, 'https://other.example/users/accepted', '', 'accepted')",
|
||||||
|
)
|
||||||
|
.bind(viewer.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
actors.len(),
|
||||||
|
1,
|
||||||
|
"accepted rows must not appear in pending_following"
|
||||||
|
);
|
||||||
|
// The handle-fallback-on-join-miss behavior is covered by
|
||||||
|
// `remote_actor_with_no_cached_row_falls_back_to_its_actor_url`; this test
|
||||||
|
// only needs to check pending-row filtering, so it asserts on identity.
|
||||||
|
assert_eq!(
|
||||||
|
actors[0].identity,
|
||||||
|
SocialIdentity::Remote {
|
||||||
|
actor_url: "https://other.example/users/pending".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn remote_actor_with_no_cached_row_falls_back_to_its_actor_url() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let viewer = uuid::Uuid::new_v4();
|
||||||
|
let orphan = "https://other.example/users/uncached";
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, ?2, '', 'pending')",
|
||||||
|
)
|
||||||
|
.bind(viewer.to_string())
|
||||||
|
.bind(orphan)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// deliberately NO ap_remote_actors row for `orphan`
|
||||||
|
|
||||||
|
let actors = FollowQuery::get_pending_following(&repo(pool), viewer)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(actors.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
actors[0].handle, orphan,
|
||||||
|
"with no cached actor, handle must fall back to the actor url, not render empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn count_pending_followers_counts_only_pending() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let owner = uuid::Uuid::new_v4();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO ap_followers (local_user_id, remote_actor_url, follow_activity_id, status)
|
||||||
|
VALUES (?1, 'https://other.example/users/a', '', 'pending'),
|
||||||
|
(?1, 'https://other.example/users/b', '', 'pending'),
|
||||||
|
(?1, 'https://other.example/users/c', '', 'accepted'),
|
||||||
|
(?1, 'https://other.example/users/d', '', 'rejected')",
|
||||||
|
)
|
||||||
|
.bind(owner.to_string())
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let n = FollowQuery::count_pending_followers(&repo(pool), owner)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
n, 2,
|
||||||
|
"only pending rows count; accepted and rejected must not"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn setup_admin_query_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_list_all_followed_remote_actors_deduplicates() {
|
||||||
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||||
|
setup_admin_query_db(&pool).await;
|
||||||
|
let repo =
|
||||||
|
SqliteSocialRepository::new(pool.clone(), InstanceIdentity::new("https://localhost"));
|
||||||
|
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");
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ use async_trait::async_trait;
|
|||||||
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
|
|
||||||
use super::SqliteFederationRepository;
|
use super::SqliteSocialRepository;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl RemoteWatchlistRepository for SqliteFederationRepository {
|
impl RemoteWatchlistRepository for SqliteSocialRepository {
|
||||||
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO ap_remote_watchlist_entries \
|
"INSERT INTO ap_remote_watchlist_entries \
|
||||||
@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
|
|||||||
|
|
||||||
adapter-common = { workspace = true }
|
adapter-common = { workspace = true }
|
||||||
domain = { workspace = true }
|
domain = { workspace = true }
|
||||||
sqlite-federation = { workspace = true }
|
sqlite-social = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ pub use profile::SqliteMovieProfileRepository;
|
|||||||
pub use profile_fields::SqliteProfileFieldsRepository;
|
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||||
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
pub use refresh_sessions::SqliteRefreshSessionAdapter;
|
||||||
pub use review::SqliteReviewRepository;
|
pub use review::SqliteReviewRepository;
|
||||||
pub use sqlite_federation::SqliteApContentQuery;
|
pub use sqlite_social::SqliteApContentQuery;
|
||||||
pub use stats::SqliteStatsRepository;
|
pub use stats::SqliteStatsRepository;
|
||||||
pub use users::SqliteUserRepository;
|
pub use users::SqliteUserRepository;
|
||||||
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
pub use watch_event::{SqliteWatchEventRepository, SqliteWebhookTokenRepository};
|
||||||
@@ -118,7 +118,7 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
|
|||||||
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
|
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::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(sqlite_federation::SqliteRemoteGoalRepository::new(
|
remote_goal: std::sync::Arc::new(sqlite_social::SqliteRemoteGoalRepository::new(
|
||||||
pool.clone(),
|
pool.clone(),
|
||||||
)) as _,
|
)) as _,
|
||||||
deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _,
|
deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _,
|
||||||
|
|||||||
@@ -208,7 +208,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
|||||||
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::<i64, _>("billing_order").unwrap_or(0) as u32,
|
billing_order: r.try_get::<i64, _>("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();
|
||||||
|
|
||||||
@@ -226,31 +229,40 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
|||||||
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<i64>, _>("runtime_minutes")
|
.try_get::<Option<i64>, _>("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<i64>, _>("vote_count")
|
.try_get::<Option<i64>, _>("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,
|
||||||
@@ -286,3 +298,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/profile.rs"]
|
||||||
|
mod tests;
|
||||||
|
|||||||
95
crates/adapters/sqlite/src/tests/profile.rs
Normal file
95
crates/adapters/sqlite/src/tests/profile.rs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
use super::super::profile::SqliteMovieProfileRepository;
|
||||||
|
use domain::{ports::MovieProfileRepository, value_objects::MovieId};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
async fn pool_with_schema() -> SqlitePool {
|
||||||
|
let pool = SqlitePool::connect("sqlite::memory:").await.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE movie_profiles (
|
||||||
|
movie_id TEXT PRIMARY KEY, tmdb_id INTEGER, imdb_id TEXT,
|
||||||
|
overview TEXT, tagline TEXT, runtime_minutes INTEGER,
|
||||||
|
budget_usd INTEGER, revenue_usd INTEGER, vote_average REAL,
|
||||||
|
vote_count INTEGER, original_language TEXT, collection_name TEXT,
|
||||||
|
enriched_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE movie_genres (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query("CREATE TABLE movie_keywords (movie_id TEXT, tmdb_id INTEGER, name TEXT)")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE movie_cast (movie_id TEXT, tmdb_person_id INTEGER,
|
||||||
|
name TEXT, character TEXT, billing_order INTEGER, profile_path TEXT)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE movie_crew (movie_id TEXT, tmdb_person_id INTEGER,
|
||||||
|
name TEXT, job TEXT, department TEXT, profile_path TEXT)",
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
pool
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn insert_bare_profile(pool: &SqlitePool, movie_id: &str) {
|
||||||
|
sqlx::query("INSERT INTO movie_profiles (movie_id, tmdb_id, enriched_at) VALUES (?, 1, ?)")
|
||||||
|
.bind(movie_id)
|
||||||
|
.bind(chrono::Utc::now().to_rfc3339())
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn null_cast_profile_path_becomes_none_not_empty_string() {
|
||||||
|
let pool = pool_with_schema().await;
|
||||||
|
let movie_id = MovieId::generate();
|
||||||
|
let movie_id_str = movie_id.value().to_string();
|
||||||
|
|
||||||
|
insert_bare_profile(&pool, &movie_id_str).await;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
|
||||||
|
VALUES (?, 1, 'Alice', 'Hero', 0, NULL)",
|
||||||
|
)
|
||||||
|
.bind(&movie_id_str)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||||
|
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(profile.cast.len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
profile.cast[0].profile_path, None,
|
||||||
|
"NULL profile_path must decode to None, not Some(\"\")"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn null_budget_usd_becomes_none_not_some_zero() {
|
||||||
|
let pool = pool_with_schema().await;
|
||||||
|
let movie_id = MovieId::generate();
|
||||||
|
let movie_id_str = movie_id.value().to_string();
|
||||||
|
|
||||||
|
insert_bare_profile(&pool, &movie_id_str).await;
|
||||||
|
|
||||||
|
let adapter = SqliteMovieProfileRepository::new(pool);
|
||||||
|
let profile = adapter.get_by_movie_id(&movie_id).await.unwrap().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
profile.budget_usd, None,
|
||||||
|
"NULL budget_usd must decode to None, not Some(0)"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -296,6 +296,7 @@ pub struct FollowingTemplate {
|
|||||||
pub ctx: HtmlPageContext,
|
pub ctx: HtmlPageContext,
|
||||||
pub user_id: uuid::Uuid,
|
pub user_id: uuid::Uuid,
|
||||||
pub actors: Vec<RemoteActorData>,
|
pub actors: Vec<RemoteActorData>,
|
||||||
|
pub pending_actors: Vec<RemoteActorData>,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<a href="/">Feed</a>
|
<a href="/">Feed</a>
|
||||||
<a href="/users">Users</a>
|
<a href="/users">Users</a>
|
||||||
{% if let Some(uid) = ctx.user_id %}
|
{% if let Some(uid) = ctx.user_id %}
|
||||||
<a href="/users/{{ uid }}">Profile</a>
|
<a href="/users/{{ uid }}">Profile{% if ctx.pending_follow_count > 0 %} ({{ ctx.pending_follow_count }}){% endif %}</a>
|
||||||
<a href="/reviews/new">Add Review</a>
|
<a href="/reviews/new">Add Review</a>
|
||||||
<a href="/import">Import</a>
|
<a href="/import">Import</a>
|
||||||
<a href="/watch-queue">Queue</a>
|
<a href="/watch-queue">Queue</a>
|
||||||
|
|||||||
@@ -10,6 +10,28 @@
|
|||||||
<input type="text" name="handle" placeholder="@user@instance.tld" required>
|
<input type="text" name="handle" placeholder="@user@instance.tld" required>
|
||||||
<button type="submit">Follow</button>
|
<button type="submit">Follow</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% if !pending_actors.is_empty() %}
|
||||||
|
<h3>Requested ({{ pending_actors.len() }})</h3>
|
||||||
|
<ul class="following-list">
|
||||||
|
{% for actor in pending_actors %}
|
||||||
|
<li class="following-item">
|
||||||
|
{% if let Some(avatar) = actor.avatar_url %}
|
||||||
|
<img src="{{ avatar }}" alt="" style="width:32px;height:32px;border-radius:50%;vertical-align:middle;margin-right:6px" />
|
||||||
|
{% endif %}
|
||||||
|
<strong>{{ actor.handle }}</strong>
|
||||||
|
{% if let Some(name) = actor.display_name %}
|
||||||
|
({{ name }})
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ actor.url }}" target="_blank" rel="noopener noreferrer">View profile ↗</a>
|
||||||
|
<form method="POST" action="/users/{{ user_id }}/unfollow" style="display:inline">
|
||||||
|
<input type="hidden" name="actor_url" value="{{ actor.url }}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||||
|
<button type="submit">Cancel request</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
{% if actors.is_empty() %}
|
{% if actors.is_empty() %}
|
||||||
<p>Not following anyone yet. Follow remote users from your <a href="/users/{{ user_id }}">profile page</a>.</p>
|
<p>Not following anyone yet. Follow remote users from your <a href="/users/{{ user_id }}">profile page</a>.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ serde = { workspace = true }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
|
||||||
domain = { path = "../domain" }
|
domain = { path = "../domain" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub struct HtmlPageContext {
|
|||||||
pub canonical_url: String,
|
pub canonical_url: String,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub page_rss_url: Option<String>,
|
pub page_rss_url: Option<String>,
|
||||||
|
pub pending_follow_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HtmlPageContext {
|
impl HtmlPageContext {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
pub struct FollowRequest {
|
pub struct FollowRequest {
|
||||||
@@ -15,6 +16,9 @@ pub struct RemoteActorDto {
|
|||||||
pub handle: String,
|
pub handle: String,
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// `Some` for local actors, so the SPA can link internally to `/users/{id}`.
|
||||||
|
pub user_id: Option<Uuid>,
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
@@ -42,3 +46,62 @@ pub struct BlockedActorResponse {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, utoipa::ToSchema, Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FollowStateDto {
|
||||||
|
None,
|
||||||
|
Pending,
|
||||||
|
Accepted,
|
||||||
|
Rejected,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<domain::value_objects::FollowStatus>> for FollowStateDto {
|
||||||
|
fn from(s: Option<domain::value_objects::FollowStatus>) -> Self {
|
||||||
|
use domain::value_objects::FollowStatus as F;
|
||||||
|
match s {
|
||||||
|
None => Self::None,
|
||||||
|
Some(F::Pending) => Self::Pending,
|
||||||
|
Some(F::Accepted) => Self::Accepted,
|
||||||
|
Some(F::Rejected) => Self::Rejected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct FollowRelationResponse {
|
||||||
|
pub following: FollowStateDto,
|
||||||
|
pub followed_by: FollowStateDto,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||||
|
pub struct PendingCountResponse {
|
||||||
|
pub count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Task 7's SPA zod schema parses these exact lowercase literals —
|
||||||
|
/// a casing or naming drift here breaks the SPA at runtime.
|
||||||
|
#[test]
|
||||||
|
fn follow_state_dto_serializes_to_lowercase_strings() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::None).unwrap(),
|
||||||
|
"\"none\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Pending).unwrap(),
|
||||||
|
"\"pending\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Accepted).unwrap(),
|
||||||
|
"\"accepted\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&FollowStateDto::Rejected).unwrap(),
|
||||||
|
"\"rejected\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ pub struct RegisterAndLoginDeps {
|
|||||||
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct LogoutDeps {
|
||||||
|
pub refresh_session: Arc<dyn RefreshSessionRepository>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
use std::sync::Arc;
|
use domain::errors::DomainError;
|
||||||
|
|
||||||
use domain::{errors::DomainError, ports::RefreshSessionRepository};
|
use crate::auth::deps::LogoutDeps;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(deps: &LogoutDeps, refresh_token: &str) -> Result<(), DomainError> {
|
||||||
refresh_session: Arc<dyn RefreshSessionRepository>,
|
deps.refresh_session.revoke(refresh_token).await
|
||||||
refresh_token: &str,
|
|
||||||
) -> Result<(), DomainError> {
|
|
||||||
refresh_session.revoke(refresh_token).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use domain::testing::InMemoryUserRepository;
|
|||||||
use crate::{
|
use crate::{
|
||||||
auth::{
|
auth::{
|
||||||
commands::RegisterCommand,
|
commands::RegisterCommand,
|
||||||
deps::{LoginDeps, RefreshDeps, RegisterDeps},
|
deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps},
|
||||||
login, logout,
|
login, logout,
|
||||||
queries::LoginCommand,
|
queries::LoginCommand,
|
||||||
refresh, register,
|
refresh, register,
|
||||||
@@ -53,7 +53,10 @@ async fn logout_revokes_refresh_token() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
logout::execute(b.refresh_session_repo.clone(), &login_result.refresh_token)
|
let logout_deps = LogoutDeps {
|
||||||
|
refresh_session: b.refresh_session_repo.clone(),
|
||||||
|
};
|
||||||
|
logout::execute(&logout_deps, &login_result.refresh_token)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -69,6 +72,9 @@ async fn logout_revokes_refresh_token() {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn logout_with_unknown_token_succeeds() {
|
async fn logout_with_unknown_token_succeeds() {
|
||||||
let b = TestContextBuilder::new();
|
let b = TestContextBuilder::new();
|
||||||
let result = logout::execute(b.refresh_session_repo.clone(), "nonexistent-token").await;
|
let logout_deps = LogoutDeps {
|
||||||
|
refresh_session: b.refresh_session_repo.clone(),
|
||||||
|
};
|
||||||
|
let result = logout::execute(&logout_deps, "nonexistent-token").await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
194
crates/application/src/deps.rs
Normal file
194
crates/application/src/deps.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use domain::ports::{EventPublisher, MediaServerParser, ObjectStorage, PersonEnrichmentClient};
|
||||||
|
|
||||||
|
use crate::auth::deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterAndLoginDeps, RegisterDeps};
|
||||||
|
use crate::diary::deps::{
|
||||||
|
DeleteReviewDeps, EditReviewDeps, ExportDiaryDeps, GetActivityFeedDeps, GetDiaryDeps,
|
||||||
|
GetMovieSocialPageDeps, GetReviewHistoryDeps, GetUserFeedDeps,
|
||||||
|
};
|
||||||
|
use crate::goals::deps::{GoalCommandDeps, GoalQueryDeps};
|
||||||
|
use crate::import::deps::{
|
||||||
|
ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps, CreateSessionDeps,
|
||||||
|
DeleteImportProfileDeps, ExecuteImportDeps, GetMappingStageDeps, GetPreviewStageDeps,
|
||||||
|
GetSessionStateDeps, ListImportProfilesDeps, SaveProfileDeps,
|
||||||
|
};
|
||||||
|
use crate::integrations::deps::{
|
||||||
|
ConfirmWatchEventsDeps, DismissWatchEventsDeps, GenerateWebhookTokenDeps, GetWatchQueueDeps,
|
||||||
|
GetWebhookTokensDeps, IngestWatchEventDeps, RevokeWebhookTokenDeps,
|
||||||
|
};
|
||||||
|
use crate::movies::deps::{
|
||||||
|
EnrichMovieDeps, GetMovieProfileDeps, GetMoviesDeps, ReindexSearchDeps, SyncPosterDeps,
|
||||||
|
};
|
||||||
|
use crate::movies::merge_duplicates::MergeDuplicatesDeps;
|
||||||
|
use crate::person::deps::{EnrichPersonDeps, GetPersonDeps};
|
||||||
|
use crate::search::deps::SearchDeps;
|
||||||
|
use crate::social::deps::{SocialCommandDeps, SocialQueryDeps};
|
||||||
|
use crate::users::deps::{
|
||||||
|
AuthorizeAdminDeps, DeleteAccountDeps, GetCurrentProfileDeps, GetFederatedProfileDeps,
|
||||||
|
GetFederatedProfileStatsDeps, GetLocalProfileDeps, GetPageViewerDeps, GetProfileSettingsDeps,
|
||||||
|
GetSettingsDeps, GetUsersListDeps, ResolveUsernameDeps, UpdateProfileDeps,
|
||||||
|
UpdateProfileFieldsDeps, UpdateSettingsDeps,
|
||||||
|
};
|
||||||
|
use crate::watchlist::deps::{
|
||||||
|
GetWatchlistDeps, GetWatchlistForOwnerDeps, IsOnWatchlistDeps, RemoveFromWatchlistDeps,
|
||||||
|
WatchlistAddDeps,
|
||||||
|
};
|
||||||
|
use crate::wrapup::deps::{
|
||||||
|
DeleteWrapUpDeps, GenerateWrapUpDeps, GetReadyReportDeps, GetWrapUpDeps,
|
||||||
|
HandleWrapUpRequestedDeps, ListWrapUpsDeps,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct AuthGroup {
|
||||||
|
pub login: LoginDeps,
|
||||||
|
pub register: RegisterDeps,
|
||||||
|
pub refresh: RefreshDeps,
|
||||||
|
pub register_and_login: RegisterAndLoginDeps,
|
||||||
|
pub logout: LogoutDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DiaryGroup {
|
||||||
|
pub delete_review: DeleteReviewDeps,
|
||||||
|
pub edit_review: EditReviewDeps,
|
||||||
|
pub get_movie_social_page: GetMovieSocialPageDeps,
|
||||||
|
pub get_activity_feed: GetActivityFeedDeps,
|
||||||
|
pub get_user_feed: GetUserFeedDeps,
|
||||||
|
pub get_diary: GetDiaryDeps,
|
||||||
|
pub get_review_history: GetReviewHistoryDeps,
|
||||||
|
pub export_diary: ExportDiaryDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GoalsGroup {
|
||||||
|
pub command: GoalCommandDeps,
|
||||||
|
pub query: GoalQueryDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImportGroup {
|
||||||
|
pub create_session: CreateSessionDeps,
|
||||||
|
pub apply_mapping: ApplyMappingDeps,
|
||||||
|
pub apply_profile: ApplyProfileDeps,
|
||||||
|
pub execute_import: ExecuteImportDeps,
|
||||||
|
pub save_profile: SaveProfileDeps,
|
||||||
|
pub get_mapping_stage: GetMappingStageDeps,
|
||||||
|
pub get_preview_stage: GetPreviewStageDeps,
|
||||||
|
pub get_session_state: GetSessionStateDeps,
|
||||||
|
pub apply_profile_and_map: ApplyProfileAndMapDeps,
|
||||||
|
pub delete_profile: DeleteImportProfileDeps,
|
||||||
|
pub list_profiles: ListImportProfilesDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct IntegrationsGroup {
|
||||||
|
pub ingest_watch_event: IngestWatchEventDeps,
|
||||||
|
pub confirm_watch_events: ConfirmWatchEventsDeps,
|
||||||
|
pub dismiss_watch_events: DismissWatchEventsDeps,
|
||||||
|
pub generate_webhook_token: GenerateWebhookTokenDeps,
|
||||||
|
pub get_watch_queue: GetWatchQueueDeps,
|
||||||
|
pub get_webhook_tokens: GetWebhookTokensDeps,
|
||||||
|
pub revoke_webhook_token: RevokeWebhookTokenDeps,
|
||||||
|
/// Webhook payload parsers. Held on the group rather than inside
|
||||||
|
/// `IngestWatchEventDeps` because `ingest::execute` takes the parser as an
|
||||||
|
/// argument — the caller picks which one per route.
|
||||||
|
pub jellyfin_parser: Arc<dyn MediaServerParser>,
|
||||||
|
pub plex_parser: Arc<dyn MediaServerParser>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct MoviesGroup {
|
||||||
|
pub sync_poster: SyncPosterDeps,
|
||||||
|
pub get_movie_profile: GetMovieProfileDeps,
|
||||||
|
pub get_movies: GetMoviesDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PersonGroup {
|
||||||
|
pub get_person: GetPersonDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SearchGroup {
|
||||||
|
pub execute: SearchDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SocialGroup {
|
||||||
|
pub command: SocialCommandDeps,
|
||||||
|
pub query: SocialQueryDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UsersGroup {
|
||||||
|
pub get_local_profile: GetLocalProfileDeps,
|
||||||
|
pub get_federated_profile_stats: GetFederatedProfileStatsDeps,
|
||||||
|
pub get_page_viewer: GetPageViewerDeps,
|
||||||
|
pub resolve_username: ResolveUsernameDeps,
|
||||||
|
pub get_profile_settings: GetProfileSettingsDeps,
|
||||||
|
pub get_users_list: GetUsersListDeps,
|
||||||
|
pub update_profile: UpdateProfileDeps,
|
||||||
|
/// Not reachable from the server binary; see the `Deps`-level note above. Pre-existing dead use case: `users::delete_account::execute` has zero callers anywhere in the workspace, worker included.
|
||||||
|
pub delete_account: DeleteAccountDeps,
|
||||||
|
pub get_current_profile: GetCurrentProfileDeps,
|
||||||
|
pub update_profile_fields: UpdateProfileFieldsDeps,
|
||||||
|
pub get_settings: GetSettingsDeps,
|
||||||
|
pub update_settings: UpdateSettingsDeps,
|
||||||
|
pub authorize_admin: AuthorizeAdminDeps,
|
||||||
|
pub get_federated_profile: GetFederatedProfileDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WatchlistGroup {
|
||||||
|
pub add: WatchlistAddDeps,
|
||||||
|
pub get_watchlist_for_owner: GetWatchlistForOwnerDeps,
|
||||||
|
pub get_watchlist: GetWatchlistDeps,
|
||||||
|
pub is_on_watchlist: IsOnWatchlistDeps,
|
||||||
|
pub remove_from_watchlist: RemoveFromWatchlistDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct WrapupGroup {
|
||||||
|
pub get_ready_report: GetReadyReportDeps,
|
||||||
|
pub delete_wrapup: DeleteWrapUpDeps,
|
||||||
|
pub generate: GenerateWrapUpDeps,
|
||||||
|
pub get_wrapup: GetWrapUpDeps,
|
||||||
|
pub list_wrapups: ListWrapUpsDeps,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every deps struct a handler can need, built once by the composition root.
|
||||||
|
/// Use cases still receive only their own narrow struct — nothing takes `&Deps`.
|
||||||
|
///
|
||||||
|
/// `composition::build_deps` is called only from `crates/presentation/src/main.rs`.
|
||||||
|
/// The worker-only groups this used to also carry now live in `WorkerDeps`, built by
|
||||||
|
/// `composition::build_worker_deps` and consumed by `crates/worker/src/main.rs` —
|
||||||
|
/// `crates/worker` no longer wires its own deps (its former `db.rs` is gone). Nothing
|
||||||
|
/// in `Deps` below is worker-only anymore, except one field with no consumer anywhere
|
||||||
|
/// in the workspace — see its comment.
|
||||||
|
pub struct Deps {
|
||||||
|
pub auth: AuthGroup,
|
||||||
|
pub diary: DiaryGroup,
|
||||||
|
pub goals: GoalsGroup,
|
||||||
|
pub import: ImportGroup,
|
||||||
|
pub integrations: IntegrationsGroup,
|
||||||
|
pub movies: MoviesGroup,
|
||||||
|
pub person: PersonGroup,
|
||||||
|
pub search: SearchGroup,
|
||||||
|
pub social: SocialGroup,
|
||||||
|
pub users: UsersGroup,
|
||||||
|
pub watchlist: WatchlistGroup,
|
||||||
|
pub wrapup: WrapupGroup,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ports the worker binary can actually construct — a strict subset of
|
||||||
|
/// `Services`. The worker has no `auth`, `password_hasher`, `diary_exporter`,
|
||||||
|
/// `document_parser`, or `review_logger`; those ports have no worker-side use case,
|
||||||
|
/// so `WorkerServices` simply does not carry them (see ADR / task-2 brief for why
|
||||||
|
/// this is a separate struct rather than an `Option`-riddled `Services`).
|
||||||
|
pub struct WorkerServices {
|
||||||
|
pub object_storage: Arc<dyn ObjectStorage>,
|
||||||
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
|
/// `Option` here mirrors `Services::person_enrichment` — genuine optional
|
||||||
|
/// configuration, not a container-shape workaround.
|
||||||
|
pub person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The deps structs the worker binary needs, built by `composition::build_worker_deps`.
|
||||||
|
/// These are the five groups that moved out of `Deps` during worker unification —
|
||||||
|
/// they have no consumer the server binary can ever reach.
|
||||||
|
pub struct WorkerDeps {
|
||||||
|
pub enrich_movie: EnrichMovieDeps,
|
||||||
|
pub reindex_search: ReindexSearchDeps,
|
||||||
|
pub merge_duplicates: MergeDuplicatesDeps,
|
||||||
|
pub enrich_person: EnrichPersonDeps,
|
||||||
|
pub handle_requested: HandleWrapUpRequestedDeps,
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository,
|
DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand,
|
||||||
SocialQuery,
|
MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::AppConfig;
|
use crate::config::AppConfig;
|
||||||
@@ -27,6 +27,24 @@ pub struct GetMovieSocialPageDeps {
|
|||||||
|
|
||||||
pub struct GetActivityFeedDeps {
|
pub struct GetActivityFeedDeps {
|
||||||
pub diary: Arc<dyn DiaryQuery>,
|
pub diary: Arc<dyn DiaryQuery>,
|
||||||
pub social_query: Arc<dyn SocialQuery>,
|
pub social_query: Arc<dyn FollowGraphQuery>,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetUserFeedDeps {
|
||||||
|
pub user: Arc<dyn UserRepository>,
|
||||||
|
pub diary: Arc<dyn DiaryQuery>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetDiaryDeps {
|
||||||
|
pub diary: Arc<dyn DiaryQuery>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetReviewHistoryDeps {
|
||||||
|
pub diary: Arc<dyn DiaryQuery>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ExportDiaryDeps {
|
||||||
|
pub diary: Arc<dyn DiaryQuery>,
|
||||||
|
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use domain::{
|
use domain::{errors::DomainError, value_objects::UserId};
|
||||||
errors::DomainError,
|
|
||||||
ports::{DiaryExporter, DiaryQuery},
|
|
||||||
value_objects::UserId,
|
|
||||||
};
|
|
||||||
use futures::stream::BoxStream;
|
use futures::stream::BoxStream;
|
||||||
|
|
||||||
|
use crate::diary::deps::ExportDiaryDeps;
|
||||||
use crate::diary::queries::ExportQuery;
|
use crate::diary::queries::ExportQuery;
|
||||||
|
|
||||||
pub fn execute(
|
pub fn execute(
|
||||||
diary: &Arc<dyn DiaryQuery>,
|
deps: &ExportDiaryDeps,
|
||||||
diary_exporter: &Arc<dyn DiaryExporter>,
|
|
||||||
query: ExportQuery,
|
query: ExportQuery,
|
||||||
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
) -> BoxStream<'static, Result<Bytes, DomainError>> {
|
||||||
let user_id = UserId::from_uuid(query.user_id);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
let entry_stream = diary.stream_user_history(user_id);
|
let entry_stream = deps.diary.stream_user_history(user_id);
|
||||||
diary_exporter.stream_entries(entry_stream, query.format)
|
deps.diary_exporter
|
||||||
|
.stream_entries(entry_stream, query.format)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
DiaryEntry, DiaryFilter, ReviewSortBy,
|
DiaryEntry, DiaryFilter, ReviewSortBy,
|
||||||
collections::{PageParams, Paginated},
|
collections::{PageParams, Paginated},
|
||||||
},
|
},
|
||||||
ports::DiaryQuery,
|
|
||||||
value_objects::{MovieId, UserId},
|
value_objects::{MovieId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::diary::deps::GetDiaryDeps;
|
||||||
use crate::diary::queries::GetDiaryQuery;
|
use crate::diary::queries::GetDiaryQuery;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
diary: &Arc<dyn DiaryQuery>,
|
deps: &GetDiaryDeps,
|
||||||
query: GetDiaryQuery,
|
query: GetDiaryQuery,
|
||||||
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
) -> Result<Paginated<DiaryEntry>, DomainError> {
|
||||||
let page = PageParams::new(query.limit, query.offset)?;
|
let page = PageParams::new(query.limit, query.offset)?;
|
||||||
@@ -29,7 +27,7 @@ pub async fn execute(
|
|||||||
include_remote: user_id.is_some(),
|
include_remote: user_id.is_some(),
|
||||||
};
|
};
|
||||||
|
|
||||||
diary.query_diary(&filter).await
|
deps.diary.query_diary(&filter).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,22 +1,20 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::ReviewHistory,
|
models::ReviewHistory,
|
||||||
ports::DiaryQuery,
|
|
||||||
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
services::review_history::{ReviewHistoryAnalyzer, Trend},
|
||||||
value_objects::MovieId,
|
value_objects::MovieId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::diary::deps::GetReviewHistoryDeps;
|
||||||
use crate::diary::queries::GetReviewHistoryQuery;
|
use crate::diary::queries::GetReviewHistoryQuery;
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
diary: &Arc<dyn DiaryQuery>,
|
deps: &GetReviewHistoryDeps,
|
||||||
query: GetReviewHistoryQuery,
|
query: GetReviewHistoryQuery,
|
||||||
) -> Result<(ReviewHistory, Trend), DomainError> {
|
) -> Result<(ReviewHistory, Trend), DomainError> {
|
||||||
let movie_id = MovieId::from_uuid(query.movie_id);
|
let movie_id = MovieId::from_uuid(query.movie_id);
|
||||||
|
|
||||||
let mut history = diary.get_review_history(&movie_id).await?;
|
let mut history = deps.diary.get_review_history(&movie_id).await?;
|
||||||
|
|
||||||
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;
|
||||||
|
|
||||||
|
|||||||
61
crates/application/src/diary/get_user_feed.rs
Normal file
61
crates/application/src/diary/get_user_feed.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError, models::DiaryEntry, models::ReviewSortBy, value_objects::UserId,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::diary::deps::{GetDiaryDeps, GetUserFeedDeps};
|
||||||
|
use crate::diary::get_diary;
|
||||||
|
use crate::diary::queries::GetDiaryQuery;
|
||||||
|
|
||||||
|
/// The RSS feed's author line — derived the same way the deleted handler code
|
||||||
|
/// derived its page title: from the local part of the user's email, not their
|
||||||
|
/// username.
|
||||||
|
pub struct FeedAuthor {
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserFeed {
|
||||||
|
pub author: FeedAuthor,
|
||||||
|
pub entries: Vec<DiaryEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &GetUserFeedDeps,
|
||||||
|
user_id: Uuid,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<UserFeed, DomainError> {
|
||||||
|
let user = deps
|
||||||
|
.user
|
||||||
|
.find_by_id(&UserId::from_uuid(user_id))
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound(format!("User {user_id}")))?;
|
||||||
|
|
||||||
|
let query = GetDiaryQuery {
|
||||||
|
limit: Some(limit),
|
||||||
|
offset: Some(0),
|
||||||
|
sort_by: Some(ReviewSortBy::Descending),
|
||||||
|
movie_id: None,
|
||||||
|
user_id: Some(user_id),
|
||||||
|
};
|
||||||
|
let get_diary_deps = GetDiaryDeps {
|
||||||
|
diary: deps.diary.clone(),
|
||||||
|
};
|
||||||
|
let page = get_diary::execute(&get_diary_deps, query).await?;
|
||||||
|
|
||||||
|
let display_name = user
|
||||||
|
.email()
|
||||||
|
.value()
|
||||||
|
.split('@')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("User")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(UserFeed {
|
||||||
|
author: FeedAuthor { display_name },
|
||||||
|
entries: page.items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_user_feed.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -7,6 +7,7 @@ pub mod get_activity_feed;
|
|||||||
pub mod get_diary;
|
pub mod get_diary;
|
||||||
pub mod get_movie_social_page;
|
pub mod get_movie_social_page;
|
||||||
pub mod get_review_history;
|
pub mod get_review_history;
|
||||||
|
pub mod get_user_feed;
|
||||||
pub mod log_review;
|
pub mod log_review;
|
||||||
pub mod movie_resolver;
|
pub mod movie_resolver;
|
||||||
pub mod queries;
|
pub mod queries;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use domain::errors::DomainError;
|
use domain::errors::DomainError;
|
||||||
use domain::testing::InMemorySocialRepository;
|
use domain::testing::InMemorySocialRepository;
|
||||||
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
|
use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
|
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
|
||||||
@@ -66,7 +66,7 @@ async fn returns_feed_with_following_filter() {
|
|||||||
struct FakeSocialWithFollowing(Vec<SocialActor>);
|
struct FakeSocialWithFollowing(Vec<SocialActor>);
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl domain::ports::SocialQuery for FakeSocialWithFollowing {
|
impl domain::ports::FollowGraphQuery for FakeSocialWithFollowing {
|
||||||
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
Ok(self.0.clone())
|
Ok(self.0.clone())
|
||||||
}
|
}
|
||||||
@@ -76,17 +76,24 @@ impl domain::ports::SocialQuery for FakeSocialWithFollowing {
|
|||||||
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn get_pending_followers(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
Ok(vec![])
|
Ok(vec![])
|
||||||
}
|
}
|
||||||
|
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
|
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
|
||||||
Ok(vec![])
|
Ok(0)
|
||||||
}
|
}
|
||||||
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
|
async fn get_relation(
|
||||||
Ok(false)
|
&self,
|
||||||
|
_: &UserId,
|
||||||
|
_: &SocialIdentity,
|
||||||
|
) -> Result<FollowRelation, DomainError> {
|
||||||
|
Ok(FollowRelation::default())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
use domain::testing::FakeDiaryQuery;
|
use domain::testing::FakeDiaryQuery;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{diary::get_diary, diary::queries::GetDiaryQuery};
|
use crate::{diary::deps::GetDiaryDeps, diary::get_diary, diary::queries::GetDiaryQuery};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_page() {
|
async fn returns_empty_page() {
|
||||||
let diary = FakeDiaryQuery::new() as Arc<dyn domain::ports::DiaryQuery>;
|
let diary = FakeDiaryQuery::new() as Arc<dyn domain::ports::DiaryQuery>;
|
||||||
|
let deps = GetDiaryDeps { diary };
|
||||||
|
|
||||||
let result = get_diary::execute(
|
let result = get_diary::execute(
|
||||||
&diary,
|
&deps,
|
||||||
GetDiaryQuery {
|
GetDiaryQuery {
|
||||||
limit: None,
|
limit: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use domain::{
|
|||||||
value_objects::{MovieTitle, ReleaseYear},
|
value_objects::{MovieTitle, ReleaseYear},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{diary::get_review_history, diary::queries::GetReviewHistoryQuery};
|
use crate::{
|
||||||
|
diary::deps::GetReviewHistoryDeps, diary::get_review_history,
|
||||||
|
diary::queries::GetReviewHistoryQuery,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_history() {
|
async fn returns_empty_history() {
|
||||||
@@ -23,8 +26,9 @@ async fn returns_empty_history() {
|
|||||||
let diary = domain::testing::FakeDiaryQuery::new();
|
let diary = domain::testing::FakeDiaryQuery::new();
|
||||||
diary.seed_history(movie, vec![]);
|
diary.seed_history(movie, vec![]);
|
||||||
let diary: Arc<dyn DiaryQuery> = diary;
|
let diary: Arc<dyn DiaryQuery> = diary;
|
||||||
|
let deps = GetReviewHistoryDeps { diary };
|
||||||
|
|
||||||
let (history, trend) = get_review_history::execute(&diary, GetReviewHistoryQuery { movie_id })
|
let (history, trend) = get_review_history::execute(&deps, GetReviewHistoryQuery { movie_id })
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
93
crates/application/src/diary/tests/get_user_feed.rs
Normal file
93
crates/application/src/diary/tests/get_user_feed.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::errors::DomainError;
|
||||||
|
use domain::models::{DiaryEntry, Movie, Review, UserRole, collections::Paginated};
|
||||||
|
use domain::testing::FakeDiaryQuery;
|
||||||
|
use domain::value_objects::{Email, MovieTitle, Rating, ReleaseYear, UserId};
|
||||||
|
|
||||||
|
use crate::auth::commands::RegisterCommand;
|
||||||
|
use crate::auth::deps::RegisterDeps;
|
||||||
|
use crate::auth::register;
|
||||||
|
use crate::diary::deps::GetUserFeedDeps;
|
||||||
|
use crate::diary::get_user_feed;
|
||||||
|
use crate::test_helpers::TestContextBuilder;
|
||||||
|
|
||||||
|
async fn setup_user(b: &TestContextBuilder, email: &str, username: &str) {
|
||||||
|
let deps = RegisterDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
password_hasher: b.password_hasher.clone(),
|
||||||
|
config: b.config.clone(),
|
||||||
|
};
|
||||||
|
register::execute(
|
||||||
|
&deps,
|
||||||
|
RegisterCommand {
|
||||||
|
email: email.into(),
|
||||||
|
username: username.into(),
|
||||||
|
password: "password123".into(),
|
||||||
|
role: UserRole::Standard,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_feed_carries_author_and_entries() {
|
||||||
|
let b = TestContextBuilder::new();
|
||||||
|
setup_user(&b, "feed@test.com", "feeduser").await;
|
||||||
|
|
||||||
|
let email = Email::new("feed@test.com".into()).unwrap();
|
||||||
|
let user = b.user_repo.find_by_email(&email).await.unwrap().unwrap();
|
||||||
|
let uid = user.id().value();
|
||||||
|
|
||||||
|
let diary = FakeDiaryQuery::new();
|
||||||
|
let movie = Movie::new(
|
||||||
|
None,
|
||||||
|
MovieTitle::new("Feed Movie".into()).unwrap(),
|
||||||
|
ReleaseYear::new(2020).unwrap(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let review = Review::new(
|
||||||
|
movie.id().clone(),
|
||||||
|
UserId::from_uuid(uid),
|
||||||
|
Rating::new(5).unwrap(),
|
||||||
|
None,
|
||||||
|
chrono::Utc::now().naive_utc(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
diary.set_diary_page(Paginated {
|
||||||
|
items: vec![DiaryEntry::new(movie, review)],
|
||||||
|
total_count: 1,
|
||||||
|
limit: 50,
|
||||||
|
offset: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
let deps = GetUserFeedDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
diary: Arc::clone(&diary) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let feed = get_user_feed::execute(&deps, uid, 50).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(feed.author.display_name, "feed");
|
||||||
|
assert_eq!(feed.entries.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn user_feed_is_not_found_for_unknown_user() {
|
||||||
|
let b = TestContextBuilder::new();
|
||||||
|
let deps = GetUserFeedDeps {
|
||||||
|
user: b.user_repo.clone(),
|
||||||
|
diary: b.diary_repo.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let err = match get_user_feed::execute(&deps, Uuid::new_v4(), 50).await {
|
||||||
|
Err(e) => e,
|
||||||
|
Ok(_) => panic!("expected Err(NotFound) for an unknown user id, got Ok"),
|
||||||
|
};
|
||||||
|
assert!(matches!(err, DomainError::NotFound(_)));
|
||||||
|
}
|
||||||
64
crates/application/src/import/apply_profile_and_map.rs
Normal file
64
crates/application/src/import/apply_profile_and_map.rs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
//! Absorbs `handlers/import.rs::api_apply_profile`'s three-step orchestration:
|
||||||
|
//! apply the saved profile's field mappings onto the session, reload the
|
||||||
|
//! session to read back the mappings `apply_profile` just wrote, then run
|
||||||
|
//! `apply_mapping` to regenerate `row_results` from them. All three steps used
|
||||||
|
//! to live in the handler; this use case is the only caller-visible change —
|
||||||
|
//! the two existing use cases it drives (`apply_profile::execute`,
|
||||||
|
//! `apply_mapping::execute`) are untouched, per this task's constraint against
|
||||||
|
//! reshaping already-existing use-case signatures.
|
||||||
|
|
||||||
|
use domain::{errors::DomainError, value_objects::ImportSessionId};
|
||||||
|
|
||||||
|
use crate::import::{
|
||||||
|
apply_mapping, apply_profile,
|
||||||
|
commands::{ApplyImportMappingCommand, ApplyImportProfileCommand, ApplyProfileAndMapCommand},
|
||||||
|
deps::{ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &ApplyProfileAndMapDeps,
|
||||||
|
cmd: ApplyProfileAndMapCommand,
|
||||||
|
) -> Result<Vec<domain::models::AnnotatedRow>, DomainError> {
|
||||||
|
let profile_deps = ApplyProfileDeps {
|
||||||
|
import_profile: deps.import_profile.clone(),
|
||||||
|
import_session: deps.import_session.clone(),
|
||||||
|
};
|
||||||
|
apply_profile::execute(
|
||||||
|
&profile_deps,
|
||||||
|
ApplyImportProfileCommand {
|
||||||
|
user_id: cmd.user_id,
|
||||||
|
session_id: cmd.session_id,
|
||||||
|
profile_id: cmd.profile_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let session_id = ImportSessionId::from_uuid(cmd.session_id);
|
||||||
|
let user_id = domain::value_objects::UserId::from_uuid(cmd.user_id);
|
||||||
|
let session = deps
|
||||||
|
.import_session
|
||||||
|
.get(&session_id, &user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("session not found after profile apply".into()))?;
|
||||||
|
|
||||||
|
let mappings = session.field_mappings.unwrap_or_default();
|
||||||
|
|
||||||
|
let mapping_deps = ApplyMappingDeps {
|
||||||
|
import_session: deps.import_session.clone(),
|
||||||
|
document_parser: deps.document_parser.clone(),
|
||||||
|
movie_query: deps.movie_query.clone(),
|
||||||
|
};
|
||||||
|
apply_mapping::execute(
|
||||||
|
&mapping_deps,
|
||||||
|
ApplyImportMappingCommand {
|
||||||
|
user_id: cmd.user_id,
|
||||||
|
session_id: cmd.session_id,
|
||||||
|
mappings,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/apply_profile_and_map.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -31,6 +31,12 @@ pub struct ApplyImportProfileCommand {
|
|||||||
pub profile_id: Uuid,
|
pub profile_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ApplyProfileAndMapCommand {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub profile_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct DeleteImportProfileCommand {
|
pub struct DeleteImportProfileCommand {
|
||||||
pub user_id: Uuid,
|
pub user_id: Uuid,
|
||||||
pub profile_id: Uuid,
|
pub profile_id: Uuid,
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use crate::import::commands::DeleteImportProfileCommand;
|
use crate::import::commands::DeleteImportProfileCommand;
|
||||||
|
use crate::import::deps::DeleteImportProfileDeps;
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::ImportProfileRepository,
|
|
||||||
value_objects::{ImportProfileId, UserId},
|
value_objects::{ImportProfileId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
import_profile: Arc<dyn ImportProfileRepository>,
|
deps: &DeleteImportProfileDeps,
|
||||||
cmd: DeleteImportProfileCommand,
|
cmd: DeleteImportProfileCommand,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
|
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
|
||||||
|
|
||||||
import_profile
|
deps.import_profile
|
||||||
.get(&profile_id, &user_id)
|
.get(&profile_id, &user_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
|
||||||
import_profile.delete(&profile_id).await
|
deps.import_profile.delete(&profile_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -29,3 +29,35 @@ pub struct SaveProfileDeps {
|
|||||||
pub import_session: Arc<dyn ImportSessionRepository>,
|
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||||
pub import_profile: Arc<dyn ImportProfileRepository>,
|
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct GetMappingStageDeps {
|
||||||
|
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetPreviewStageDeps {
|
||||||
|
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetSessionStateDeps {
|
||||||
|
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeleteImportProfileDeps {
|
||||||
|
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ListImportProfilesDeps {
|
||||||
|
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backs `apply_profile_and_map`, which internally drives `apply_profile::execute`
|
||||||
|
/// then `apply_mapping::execute` — these fields are exactly the union of
|
||||||
|
/// `ApplyProfileDeps` and `ApplyMappingDeps`'s fields, cloned once here and used to
|
||||||
|
/// build each nested deps struct inline at the call site (see that file's doc
|
||||||
|
/// comment for why: no use-case signature changes, per this task's constraints).
|
||||||
|
pub struct ApplyProfileAndMapDeps {
|
||||||
|
pub import_profile: Arc<dyn ImportProfileRepository>,
|
||||||
|
pub import_session: Arc<dyn ImportSessionRepository>,
|
||||||
|
pub document_parser: Arc<dyn DocumentParser>,
|
||||||
|
pub movie_query: Arc<dyn MovieQuery>,
|
||||||
|
}
|
||||||
|
|||||||
47
crates/application/src/import/get_mapping_stage.rs
Normal file
47
crates/application/src/import/get_mapping_stage.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
//! The mapping-page stage gate: a session must exist and have a `parsed_file`
|
||||||
|
//! before its columns/sample rows can be shown for field mapping. Absorbs
|
||||||
|
//! `handlers/import.rs::get_mapping_page`'s two early-return checks (session
|
||||||
|
//! missing, `parsed_file` absent) — both collapse to `NotFound` here since the
|
||||||
|
//! handler redirected to the same place (`/import`) for either.
|
||||||
|
|
||||||
|
use domain::{errors::DomainError, value_objects::ImportSessionId};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::import::deps::GetMappingStageDeps;
|
||||||
|
|
||||||
|
/// Cap on sample rows shown on the mapping page — was a bare `.take(5)` in the
|
||||||
|
/// handler.
|
||||||
|
pub const SAMPLE_ROW_LIMIT: usize = 5;
|
||||||
|
|
||||||
|
pub struct MappingStage {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub sample_rows: Vec<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &GetMappingStageDeps,
|
||||||
|
session_id: ImportSessionId,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<MappingStage, DomainError> {
|
||||||
|
let user_id = domain::value_objects::UserId::from_uuid(user_id);
|
||||||
|
let session = deps
|
||||||
|
.import_session
|
||||||
|
.get(&session_id, &user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
|
||||||
|
|
||||||
|
let parsed = session
|
||||||
|
.parsed_file
|
||||||
|
.ok_or_else(|| DomainError::NotFound("import session has no parsed file".into()))?;
|
||||||
|
|
||||||
|
let sample_rows = parsed.rows.into_iter().take(SAMPLE_ROW_LIMIT).collect();
|
||||||
|
|
||||||
|
Ok(MappingStage {
|
||||||
|
columns: parsed.columns,
|
||||||
|
sample_rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_mapping_stage.rs"]
|
||||||
|
mod tests;
|
||||||
59
crates/application/src/import/get_preview_stage.rs
Normal file
59
crates/application/src/import/get_preview_stage.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
//! The preview-page stage gate: a session must have `row_results` (i.e. a
|
||||||
|
//! mapping has already been applied) before its rows can be previewed. Serves
|
||||||
|
//! both the HTML preview handler and the API preview handler —
|
||||||
|
//! `handlers/import.rs::get_preview_page` and `::api_get_preview` — which
|
||||||
|
//! render/respond to `NotYetMapped` differently (redirect vs. status code); that
|
||||||
|
//! decision stays in the handlers, not here.
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::AnnotatedRow,
|
||||||
|
value_objects::{ImportSessionId, UserId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::import::deps::GetPreviewStageDeps;
|
||||||
|
|
||||||
|
/// The columns and mapped/annotated rows for a session whose mapping has
|
||||||
|
/// already been applied. `columns` comes from the session's `parsed_file` —
|
||||||
|
/// the HTML preview template renders it as the table header — while `rows`
|
||||||
|
/// comes from `row_results`. Not in the brief's `PreviewStage::Ready(Vec<AnnotatedRow>)`
|
||||||
|
/// sketch: the deleted `get_preview_page` handler code read both
|
||||||
|
/// `session.parsed_file.columns` and `session.row_results` to render the page,
|
||||||
|
/// so dropping `columns` here would either blank the preview table's header or
|
||||||
|
/// force the handler to re-fetch the session itself (forbidden — that's the
|
||||||
|
/// exact repo call this task removes). See task-2 report for detail.
|
||||||
|
pub struct PreviewRows {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub rows: Vec<AnnotatedRow>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum PreviewStage {
|
||||||
|
Ready(PreviewRows),
|
||||||
|
NotYetMapped,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &GetPreviewStageDeps,
|
||||||
|
session_id: ImportSessionId,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<PreviewStage, DomainError> {
|
||||||
|
let user_id = UserId::from_uuid(user_id);
|
||||||
|
let session = deps
|
||||||
|
.import_session
|
||||||
|
.get(&session_id, &user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
|
||||||
|
|
||||||
|
let Some(rows) = session.row_results else {
|
||||||
|
return Ok(PreviewStage::NotYetMapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
let columns = session.parsed_file.map(|p| p.columns).unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(PreviewStage::Ready(PreviewRows { columns, rows }))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_preview_stage.rs"]
|
||||||
|
mod tests;
|
||||||
43
crates/application/src/import/get_session_state.rs
Normal file
43
crates/application/src/import/get_session_state.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
//! Backs `handlers/import.rs::api_get_session` — a plain state query, not a
|
||||||
|
//! redirect-driving gate (the API has nothing to redirect to; a missing
|
||||||
|
//! session is just a 404).
|
||||||
|
|
||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
value_objects::{ImportSessionId, UserId},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::import::deps::GetSessionStateDeps;
|
||||||
|
|
||||||
|
pub struct SessionState {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub has_mappings: bool,
|
||||||
|
pub row_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute(
|
||||||
|
deps: &GetSessionStateDeps,
|
||||||
|
session_id: ImportSessionId,
|
||||||
|
user_id: Uuid,
|
||||||
|
) -> Result<SessionState, DomainError> {
|
||||||
|
let user_id = UserId::from_uuid(user_id);
|
||||||
|
let session = deps
|
||||||
|
.import_session
|
||||||
|
.get(&session_id, &user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
|
||||||
|
|
||||||
|
let parsed = session.parsed_file.unwrap_or_default();
|
||||||
|
let row_count = parsed.rows.len();
|
||||||
|
|
||||||
|
Ok(SessionState {
|
||||||
|
columns: parsed.columns,
|
||||||
|
has_mappings: session.field_mappings.is_some(),
|
||||||
|
row_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/get_session_state.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -1,15 +1,11 @@
|
|||||||
use std::sync::Arc;
|
use crate::import::deps::ListImportProfilesDeps;
|
||||||
|
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
|
||||||
use domain::{
|
|
||||||
errors::DomainError, models::ImportProfile, ports::ImportProfileRepository,
|
|
||||||
value_objects::UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
import_profile: Arc<dyn ImportProfileRepository>,
|
deps: &ListImportProfilesDeps,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<Vec<ImportProfile>, DomainError> {
|
) -> Result<Vec<ImportProfile>, DomainError> {
|
||||||
import_profile.list_for_user(user_id).await
|
deps.import_profile.list_for_user(user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
pub mod apply_mapping;
|
pub mod apply_mapping;
|
||||||
pub mod apply_profile;
|
pub mod apply_profile;
|
||||||
|
pub mod apply_profile_and_map;
|
||||||
pub mod cleanup;
|
pub mod cleanup;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod create_session;
|
pub mod create_session;
|
||||||
pub mod delete_profile;
|
pub mod delete_profile;
|
||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod execute;
|
pub mod execute;
|
||||||
|
pub mod get_mapping_stage;
|
||||||
|
pub mod get_preview_stage;
|
||||||
|
pub mod get_session_state;
|
||||||
pub mod list_profiles;
|
pub mod list_profiles;
|
||||||
pub mod save_profile;
|
pub mod save_profile;
|
||||||
|
|||||||
108
crates/application/src/import/tests/apply_profile_and_map.rs
Normal file
108
crates/application/src/import/tests/apply_profile_and_map.rs
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::models::import::{DomainField, Transform};
|
||||||
|
use domain::models::{FieldMapping, FileFormat, ImportProfile};
|
||||||
|
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
|
||||||
|
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
|
||||||
|
use domain::value_objects::{ImportProfileId, UserId};
|
||||||
|
|
||||||
|
use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps};
|
||||||
|
use crate::import::{
|
||||||
|
apply_profile_and_map, commands::ApplyProfileAndMapCommand,
|
||||||
|
commands::CreateImportSessionCommand, create_session,
|
||||||
|
};
|
||||||
|
use crate::test_helpers::TestContextBuilder;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fails_when_profile_not_found() {
|
||||||
|
let profiles = InMemoryImportProfileRepository::new();
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let b = TestContextBuilder::new();
|
||||||
|
|
||||||
|
let deps = ApplyProfileAndMapDeps {
|
||||||
|
import_profile: Arc::clone(&profiles) as _,
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
document_parser: b.document_parser.clone(),
|
||||||
|
movie_query: b.movie_query.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = apply_profile_and_map::execute(
|
||||||
|
&deps,
|
||||||
|
ApplyProfileAndMapCommand {
|
||||||
|
user_id: Uuid::new_v4(),
|
||||||
|
session_id: Uuid::new_v4(),
|
||||||
|
profile_id: Uuid::new_v4(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn applies_profile_then_regenerates_mapping() {
|
||||||
|
let profiles = InMemoryImportProfileRepository::new();
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let b = TestContextBuilder::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
|
let profile = ImportProfile::new(
|
||||||
|
ImportProfileId::generate(),
|
||||||
|
UserId::from_uuid(user_id),
|
||||||
|
"letterboxd".into(),
|
||||||
|
vec![FieldMapping {
|
||||||
|
source_column: "title".into(),
|
||||||
|
domain_field: DomainField::Title,
|
||||||
|
transform: Transform::Identity,
|
||||||
|
}],
|
||||||
|
Utc::now().naive_utc(),
|
||||||
|
);
|
||||||
|
let profile_id = profile.id.clone();
|
||||||
|
profiles.save(&profile).await.unwrap();
|
||||||
|
|
||||||
|
let create_deps = CreateSessionDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
document_parser: b.document_parser.clone(),
|
||||||
|
};
|
||||||
|
let created = create_session::execute(
|
||||||
|
&create_deps,
|
||||||
|
CreateImportSessionCommand {
|
||||||
|
user_id,
|
||||||
|
bytes: b"title\nTest".to_vec(),
|
||||||
|
format: FileFormat::Csv,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let deps = ApplyProfileAndMapDeps {
|
||||||
|
import_profile: Arc::clone(&profiles) as _,
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
document_parser: b.document_parser.clone(),
|
||||||
|
movie_query: b.movie_query.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = apply_profile_and_map::execute(
|
||||||
|
&deps,
|
||||||
|
ApplyProfileAndMapCommand {
|
||||||
|
user_id,
|
||||||
|
session_id: created.session_id.value(),
|
||||||
|
profile_id: profile_id.value(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!rows.is_empty());
|
||||||
|
|
||||||
|
let updated = sessions
|
||||||
|
.get(&created.session_id, &UserId::from_uuid(user_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert!(updated.row_results.is_some());
|
||||||
|
assert!(updated.field_mappings.is_some());
|
||||||
|
}
|
||||||
@@ -3,14 +3,19 @@ use std::sync::Arc;
|
|||||||
use domain::testing::InMemoryImportProfileRepository;
|
use domain::testing::InMemoryImportProfileRepository;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
|
use crate::import::{
|
||||||
|
commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn fails_when_profile_not_found() {
|
async fn fails_when_profile_not_found() {
|
||||||
let profiles = InMemoryImportProfileRepository::new();
|
let profiles = InMemoryImportProfileRepository::new();
|
||||||
|
let deps = DeleteImportProfileDeps {
|
||||||
|
import_profile: Arc::clone(&profiles) as _,
|
||||||
|
};
|
||||||
|
|
||||||
let result = delete_profile::execute(
|
let result = delete_profile::execute(
|
||||||
Arc::clone(&profiles) as _,
|
&deps,
|
||||||
DeleteImportProfileCommand {
|
DeleteImportProfileCommand {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
profile_id: Uuid::new_v4(),
|
profile_id: Uuid::new_v4(),
|
||||||
|
|||||||
68
crates/application/src/import/tests/get_mapping_stage.rs
Normal file
68
crates/application/src/import/tests/get_mapping_stage.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::models::ImportSession;
|
||||||
|
use domain::models::import::ParsedFile;
|
||||||
|
use domain::ports::ImportSessionRepository;
|
||||||
|
use domain::testing::InMemoryImportSessionRepository;
|
||||||
|
use domain::value_objects::{ImportSessionId, UserId};
|
||||||
|
|
||||||
|
use crate::import::deps::GetMappingStageDeps;
|
||||||
|
use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_mapping_stage_is_not_found_when_file_not_parsed() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
sessions.create(&session).await.unwrap();
|
||||||
|
|
||||||
|
let deps = GetMappingStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = get_mapping_stage::execute(&deps, session_id, user_id).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_mapping_stage_is_not_found_when_session_missing() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let deps = GetMappingStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
get_mapping_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_mapping_stage_returns_columns_and_capped_sample_rows() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||||
|
session.parsed_file = Some(ParsedFile {
|
||||||
|
columns: vec!["Name".into(), "Year".into()],
|
||||||
|
rows: (0..7)
|
||||||
|
.map(|i| vec![format!("row{i}"), "2020".into()])
|
||||||
|
.collect(),
|
||||||
|
});
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
sessions.create(&session).await.unwrap();
|
||||||
|
|
||||||
|
let deps = GetMappingStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stage = get_mapping_stage::execute(&deps, session_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(stage.columns, vec!["Name".to_string(), "Year".to_string()]);
|
||||||
|
assert_eq!(stage.sample_rows.len(), SAMPLE_ROW_LIMIT);
|
||||||
|
}
|
||||||
80
crates/application/src/import/tests/get_preview_stage.rs
Normal file
80
crates/application/src/import/tests/get_preview_stage.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::models::import::{ImportRow, ParsedFile, RowResult};
|
||||||
|
use domain::models::{AnnotatedRow, ImportSession};
|
||||||
|
use domain::ports::ImportSessionRepository;
|
||||||
|
use domain::testing::InMemoryImportSessionRepository;
|
||||||
|
use domain::value_objects::{ImportSessionId, UserId};
|
||||||
|
|
||||||
|
use crate::import::deps::GetPreviewStageDeps;
|
||||||
|
use crate::import::get_preview_stage::{self, PreviewStage};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let session = ImportSession::new(UserId::from_uuid(user_id));
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
sessions.create(&session).await.unwrap();
|
||||||
|
|
||||||
|
let deps = GetPreviewStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stage = get_preview_stage::execute(&deps, session_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(stage, PreviewStage::NotYetMapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_preview_stage_returns_rows_once_mapped() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||||
|
session.parsed_file = Some(ParsedFile {
|
||||||
|
columns: vec!["Name".into()],
|
||||||
|
rows: vec![vec!["Test".into()]],
|
||||||
|
});
|
||||||
|
session.row_results = Some(vec![AnnotatedRow {
|
||||||
|
result: RowResult::Valid(ImportRow {
|
||||||
|
title: Some("Test".into()),
|
||||||
|
..ImportRow::default()
|
||||||
|
}),
|
||||||
|
is_duplicate: false,
|
||||||
|
}]);
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
sessions.create(&session).await.unwrap();
|
||||||
|
|
||||||
|
let deps = GetPreviewStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let stage = get_preview_stage::execute(&deps, session_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
match stage {
|
||||||
|
PreviewStage::Ready(preview) => {
|
||||||
|
assert_eq!(preview.columns, vec!["Name".to_string()]);
|
||||||
|
assert_eq!(preview.rows.len(), 1);
|
||||||
|
}
|
||||||
|
PreviewStage::NotYetMapped => panic!("expected Ready, got NotYetMapped"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_preview_stage_is_not_found_when_session_missing() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let deps = GetPreviewStageDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
get_preview_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
50
crates/application/src/import/tests/get_session_state.rs
Normal file
50
crates/application/src/import/tests/get_session_state.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use domain::models::ImportSession;
|
||||||
|
use domain::models::import::ParsedFile;
|
||||||
|
use domain::ports::ImportSessionRepository;
|
||||||
|
use domain::testing::InMemoryImportSessionRepository;
|
||||||
|
use domain::value_objects::{ImportSessionId, UserId};
|
||||||
|
|
||||||
|
use crate::import::deps::GetSessionStateDeps;
|
||||||
|
use crate::import::get_session_state;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_session_state_is_not_found_when_session_missing() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let deps = GetSessionStateDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result =
|
||||||
|
get_session_state::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_session_state_reports_columns_row_count_and_mapping_status() {
|
||||||
|
let sessions = InMemoryImportSessionRepository::new();
|
||||||
|
let user_id = Uuid::new_v4();
|
||||||
|
let mut session = ImportSession::new(UserId::from_uuid(user_id));
|
||||||
|
session.parsed_file = Some(ParsedFile {
|
||||||
|
columns: vec!["Name".into()],
|
||||||
|
rows: vec![vec!["a".into()], vec!["b".into()]],
|
||||||
|
});
|
||||||
|
let session_id = session.id.clone();
|
||||||
|
sessions.create(&session).await.unwrap();
|
||||||
|
|
||||||
|
let deps = GetSessionStateDeps {
|
||||||
|
import_session: Arc::clone(&sessions) as _,
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = get_session_state::execute(&deps, session_id, user_id)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(state.columns, vec!["Name".to_string()]);
|
||||||
|
assert_eq!(state.row_count, 2);
|
||||||
|
assert!(!state.has_mappings);
|
||||||
|
}
|
||||||
@@ -4,16 +4,17 @@ use domain::testing::InMemoryImportProfileRepository;
|
|||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::import::list_profiles;
|
use crate::import::{deps::ListImportProfilesDeps, list_profiles};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_when_no_profiles() {
|
async fn returns_empty_when_no_profiles() {
|
||||||
let profiles = InMemoryImportProfileRepository::new();
|
let profiles = InMemoryImportProfileRepository::new();
|
||||||
|
let deps = ListImportProfilesDeps {
|
||||||
|
import_profile: Arc::clone(&profiles) as _,
|
||||||
|
};
|
||||||
|
|
||||||
let user_id = UserId::from_uuid(Uuid::new_v4());
|
let user_id = UserId::from_uuid(Uuid::new_v4());
|
||||||
let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id)
|
let result = list_profiles::execute(&deps, &user_id).await.unwrap();
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,16 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::WatchEventStatus,
|
models::WatchEventStatus,
|
||||||
ports::{WatchEventCommand, WatchEventQuery},
|
|
||||||
value_objects::{UserId, WatchEventId},
|
value_objects::{UserId, WatchEventId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
diary::commands::{LogReviewCommand, MovieInput},
|
diary::commands::{LogReviewCommand, MovieInput},
|
||||||
integrations::commands::ConfirmWatchEventsCommand,
|
integrations::{commands::ConfirmWatchEventsCommand, deps::ConfirmWatchEventsDeps},
|
||||||
ports::ReviewLogger,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
watch_event_command: Arc<dyn WatchEventCommand>,
|
deps: &ConfirmWatchEventsDeps,
|
||||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
|
||||||
review_logger: Arc<dyn ReviewLogger>,
|
|
||||||
cmd: ConfirmWatchEventsCommand,
|
cmd: ConfirmWatchEventsCommand,
|
||||||
) -> Result<u32, DomainError> {
|
) -> Result<u32, DomainError> {
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
@@ -24,7 +18,8 @@ pub async fn execute(
|
|||||||
|
|
||||||
for c in cmd.confirmations {
|
for c in cmd.confirmations {
|
||||||
let event_id = WatchEventId::from_uuid(c.watch_event_id);
|
let event_id = WatchEventId::from_uuid(c.watch_event_id);
|
||||||
let event = watch_event_query
|
let event = deps
|
||||||
|
.watch_event_query
|
||||||
.get_by_id(&event_id)
|
.get_by_id(&event_id)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
|
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
|
||||||
@@ -60,9 +55,9 @@ pub async fn execute(
|
|||||||
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
watch_medium: Some(domain::value_objects::WatchMedium::MediaServer),
|
||||||
};
|
};
|
||||||
|
|
||||||
review_logger.log_review(review_cmd).await?;
|
deps.review_logger.log_review(review_cmd).await?;
|
||||||
|
|
||||||
watch_event_command
|
deps.watch_event_command
|
||||||
.update_status(&event_id, WatchEventStatus::Confirmed)
|
.update_status(&event_id, WatchEventStatus::Confirmed)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,38 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
|
use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
|
||||||
|
|
||||||
|
use crate::ports::ReviewLogger;
|
||||||
|
|
||||||
pub struct IngestWatchEventDeps {
|
pub struct IngestWatchEventDeps {
|
||||||
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||||
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||||
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||||
pub event_publisher: Arc<dyn EventPublisher>,
|
pub event_publisher: Arc<dyn EventPublisher>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct ConfirmWatchEventsDeps {
|
||||||
|
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||||
|
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||||
|
pub review_logger: Arc<dyn ReviewLogger>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DismissWatchEventsDeps {
|
||||||
|
pub watch_event_command: Arc<dyn WatchEventCommand>,
|
||||||
|
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GenerateWebhookTokenDeps {
|
||||||
|
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetWatchQueueDeps {
|
||||||
|
pub watch_event_query: Arc<dyn WatchEventQuery>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GetWebhookTokensDeps {
|
||||||
|
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RevokeWebhookTokenDeps {
|
||||||
|
pub webhook_token: Arc<dyn WebhookTokenRepository>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::WatchEventStatus,
|
models::WatchEventStatus,
|
||||||
ports::{WatchEventCommand, WatchEventQuery},
|
|
||||||
value_objects::{UserId, WatchEventId},
|
value_objects::{UserId, WatchEventId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::integrations::commands::DismissWatchEventsCommand;
|
use crate::integrations::{commands::DismissWatchEventsCommand, deps::DismissWatchEventsDeps};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
watch_event_command: Arc<dyn WatchEventCommand>,
|
deps: &DismissWatchEventsDeps,
|
||||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
|
||||||
cmd: DismissWatchEventsCommand,
|
cmd: DismissWatchEventsCommand,
|
||||||
) -> Result<u32, DomainError> {
|
) -> Result<u32, DomainError> {
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
@@ -25,7 +21,7 @@ pub async fn execute(
|
|||||||
.map(|id| WatchEventId::from_uuid(*id))
|
.map(|id| WatchEventId::from_uuid(*id))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let events = watch_event_query.get_by_ids(&ids).await?;
|
let events = deps.watch_event_query.get_by_ids(&ids).await?;
|
||||||
|
|
||||||
if events.len() != ids.len() {
|
if events.len() != ids.len() {
|
||||||
return Err(DomainError::NotFound(
|
return Err(DomainError::NotFound(
|
||||||
@@ -38,7 +34,8 @@ pub async fn execute(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let count = watch_event_command
|
let count = deps
|
||||||
|
.watch_event_command
|
||||||
.update_status_batch(&ids, WatchEventStatus::Dismissed)
|
.update_status_batch(&ids, WatchEventStatus::Dismissed)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
use std::sync::Arc;
|
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||||
|
|
||||||
use domain::{
|
|
||||||
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
|
|
||||||
};
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::integrations::commands::GenerateWebhookTokenCommand;
|
use crate::integrations::{commands::GenerateWebhookTokenCommand, deps::GenerateWebhookTokenDeps};
|
||||||
|
|
||||||
pub struct GeneratedWebhookToken {
|
pub struct GeneratedWebhookToken {
|
||||||
pub token_plaintext: String,
|
pub token_plaintext: String,
|
||||||
@@ -13,7 +9,7 @@ pub struct GeneratedWebhookToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
deps: &GenerateWebhookTokenDeps,
|
||||||
cmd: GenerateWebhookTokenCommand,
|
cmd: GenerateWebhookTokenCommand,
|
||||||
) -> Result<GeneratedWebhookToken, DomainError> {
|
) -> Result<GeneratedWebhookToken, DomainError> {
|
||||||
let plaintext = generate_random_token();
|
let plaintext = generate_random_token();
|
||||||
@@ -22,7 +18,7 @@ pub async fn execute(
|
|||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
|
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
|
||||||
|
|
||||||
webhook_token.save(&token).await?;
|
deps.webhook_token.save(&token).await?;
|
||||||
|
|
||||||
Ok(GeneratedWebhookToken {
|
Ok(GeneratedWebhookToken {
|
||||||
token_plaintext: plaintext,
|
token_plaintext: plaintext,
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId};
|
||||||
|
|
||||||
use domain::{
|
use crate::integrations::{deps::GetWatchQueueDeps, queries::GetWatchQueueQuery};
|
||||||
errors::DomainError, models::WatchEvent, ports::WatchEventQuery, value_objects::UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::integrations::queries::GetWatchQueueQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
watch_event_query: Arc<dyn WatchEventQuery>,
|
deps: &GetWatchQueueDeps,
|
||||||
query: GetWatchQueueQuery,
|
query: GetWatchQueueQuery,
|
||||||
) -> Result<Vec<WatchEvent>, DomainError> {
|
) -> Result<Vec<WatchEvent>, DomainError> {
|
||||||
let user_id = UserId::from_uuid(query.user_id);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
watch_event_query.list_pending(&user_id).await
|
deps.watch_event_query.list_pending(&user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
|
||||||
|
|
||||||
use domain::{
|
use crate::integrations::{deps::GetWebhookTokensDeps, queries::GetWebhookTokensQuery};
|
||||||
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::integrations::queries::GetWebhookTokensQuery;
|
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
deps: &GetWebhookTokensDeps,
|
||||||
query: GetWebhookTokensQuery,
|
query: GetWebhookTokensQuery,
|
||||||
) -> Result<Vec<WebhookToken>, DomainError> {
|
) -> Result<Vec<WebhookToken>, DomainError> {
|
||||||
let user_id = UserId::from_uuid(query.user_id);
|
let user_id = UserId::from_uuid(query.user_id);
|
||||||
webhook_token.list_by_user(&user_id).await
|
deps.webhook_token.list_by_user(&user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
ports::WebhookTokenRepository,
|
|
||||||
value_objects::{UserId, WebhookTokenId},
|
value_objects::{UserId, WebhookTokenId},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::integrations::commands::RevokeWebhookTokenCommand;
|
use crate::integrations::{commands::RevokeWebhookTokenCommand, deps::RevokeWebhookTokenDeps};
|
||||||
|
|
||||||
pub async fn execute(
|
pub async fn execute(
|
||||||
webhook_token: Arc<dyn WebhookTokenRepository>,
|
deps: &RevokeWebhookTokenDeps,
|
||||||
cmd: RevokeWebhookTokenCommand,
|
cmd: RevokeWebhookTokenCommand,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let user_id = UserId::from_uuid(cmd.user_id);
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
let token_id = WebhookTokenId::from_uuid(cmd.token_id);
|
||||||
webhook_token.delete(&token_id, &user_id).await
|
deps.webhook_token.delete(&token_id, &user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation};
|
use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation};
|
||||||
use crate::integrations::confirm;
|
use crate::integrations::confirm;
|
||||||
|
use crate::integrations::deps::ConfirmWatchEventsDeps;
|
||||||
use crate::test_helpers::NoopReviewLogger;
|
use crate::test_helpers::NoopReviewLogger;
|
||||||
|
|
||||||
fn noop_logger() -> Arc<dyn crate::ports::ReviewLogger> {
|
fn noop_logger() -> Arc<dyn crate::ports::ReviewLogger> {
|
||||||
Arc::new(NoopReviewLogger)
|
Arc::new(NoopReviewLogger)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn deps(
|
||||||
|
watch_events: &Arc<InMemoryWatchEventRepository>,
|
||||||
|
review_logger: Arc<dyn crate::ports::ReviewLogger>,
|
||||||
|
) -> ConfirmWatchEventsDeps {
|
||||||
|
ConfirmWatchEventsDeps {
|
||||||
|
watch_event_command: Arc::clone(watch_events) as _,
|
||||||
|
watch_event_query: Arc::clone(watch_events) as _,
|
||||||
|
review_logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn confirms_watch_event_via_review_logger() {
|
async fn confirms_watch_event_via_review_logger() {
|
||||||
let watch_events = InMemoryWatchEventRepository::new();
|
let watch_events = InMemoryWatchEventRepository::new();
|
||||||
@@ -32,9 +44,7 @@ async fn confirms_watch_event_via_review_logger() {
|
|||||||
watch_events.save(&event).await.unwrap();
|
watch_events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -55,9 +65,7 @@ async fn empty_confirmations_returns_zero() {
|
|||||||
let watch_events = InMemoryWatchEventRepository::new();
|
let watch_events = InMemoryWatchEventRepository::new();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
confirmations: vec![],
|
confirmations: vec![],
|
||||||
@@ -87,9 +95,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
|
|||||||
watch_events.save(&event).await.unwrap();
|
watch_events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -124,9 +130,7 @@ async fn rejects_other_users_event() {
|
|||||||
watch_events.save(&event).await.unwrap();
|
watch_events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: intruder,
|
user_id: intruder,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -146,9 +150,7 @@ async fn fails_when_event_not_found() {
|
|||||||
let watch_events = InMemoryWatchEventRepository::new();
|
let watch_events = InMemoryWatchEventRepository::new();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -208,9 +210,7 @@ async fn confirms_event_with_movie_id() {
|
|||||||
));
|
));
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, review_logger),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
review_logger,
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -244,9 +244,7 @@ async fn confirms_event_without_movie_id_and_without_external_metadata_id() {
|
|||||||
watch_events.save(&event).await.unwrap();
|
watch_events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
@@ -293,9 +291,7 @@ async fn confirms_multiple_events() {
|
|||||||
watch_events.save(&event2).await.unwrap();
|
watch_events.save(&event2).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![
|
confirmations: vec![
|
||||||
@@ -336,9 +332,7 @@ async fn confirms_event_without_year() {
|
|||||||
watch_events.save(&event).await.unwrap();
|
watch_events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = confirm::execute(
|
let result = confirm::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events, noop_logger()),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
noop_logger(),
|
|
||||||
ConfirmWatchEventsCommand {
|
ConfirmWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
confirmations: vec![WatchEventConfirmation {
|
confirmations: vec![WatchEventConfirmation {
|
||||||
|
|||||||
@@ -6,15 +6,22 @@ use domain::testing::InMemoryWatchEventRepository;
|
|||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::integrations::deps::DismissWatchEventsDeps;
|
||||||
use crate::integrations::{commands::DismissWatchEventsCommand, dismiss};
|
use crate::integrations::{commands::DismissWatchEventsCommand, dismiss};
|
||||||
|
|
||||||
|
fn deps(watch_events: &Arc<InMemoryWatchEventRepository>) -> DismissWatchEventsDeps {
|
||||||
|
DismissWatchEventsDeps {
|
||||||
|
watch_event_command: Arc::clone(watch_events) as _,
|
||||||
|
watch_event_query: Arc::clone(watch_events) as _,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dismisses_empty_list_returns_zero() {
|
async fn dismisses_empty_list_returns_zero() {
|
||||||
let events = InMemoryWatchEventRepository::new();
|
let events = InMemoryWatchEventRepository::new();
|
||||||
|
|
||||||
let result = dismiss::execute(
|
let result = dismiss::execute(
|
||||||
Arc::clone(&events) as _,
|
&deps(&events),
|
||||||
Arc::clone(&events) as _,
|
|
||||||
DismissWatchEventsCommand {
|
DismissWatchEventsCommand {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
event_ids: vec![],
|
event_ids: vec![],
|
||||||
@@ -31,8 +38,7 @@ async fn fails_when_event_not_found() {
|
|||||||
let events = InMemoryWatchEventRepository::new();
|
let events = InMemoryWatchEventRepository::new();
|
||||||
|
|
||||||
let result = dismiss::execute(
|
let result = dismiss::execute(
|
||||||
Arc::clone(&events) as _,
|
&deps(&events),
|
||||||
Arc::clone(&events) as _,
|
|
||||||
DismissWatchEventsCommand {
|
DismissWatchEventsCommand {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
event_ids: vec![Uuid::new_v4()],
|
event_ids: vec![Uuid::new_v4()],
|
||||||
@@ -73,8 +79,7 @@ async fn dismisses_existing_events() {
|
|||||||
watch_events.save(&e2).await.unwrap();
|
watch_events.save(&e2).await.unwrap();
|
||||||
|
|
||||||
let result = dismiss::execute(
|
let result = dismiss::execute(
|
||||||
Arc::clone(&watch_events) as _,
|
&deps(&watch_events),
|
||||||
Arc::clone(&watch_events) as _,
|
|
||||||
DismissWatchEventsCommand {
|
DismissWatchEventsCommand {
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
event_ids: vec![id1, id2],
|
event_ids: vec![id1, id2],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ use domain::ports::WebhookTokenRepository;
|
|||||||
use domain::testing::InMemoryWebhookTokenRepository;
|
use domain::testing::InMemoryWebhookTokenRepository;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::integrations::deps::GenerateWebhookTokenDeps;
|
||||||
use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token};
|
use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -13,7 +14,9 @@ async fn generates_token_and_saves() {
|
|||||||
|
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let result = generate_token::execute(
|
let result = generate_token::execute(
|
||||||
Arc::clone(&tokens),
|
&GenerateWebhookTokenDeps {
|
||||||
|
webhook_token: Arc::clone(&tokens),
|
||||||
|
},
|
||||||
GenerateWebhookTokenCommand {
|
GenerateWebhookTokenCommand {
|
||||||
user_id,
|
user_id,
|
||||||
provider: WatchEventSource::Jellyfin,
|
provider: WatchEventSource::Jellyfin,
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ use domain::testing::InMemoryWatchEventRepository;
|
|||||||
use domain::value_objects::UserId;
|
use domain::value_objects::UserId;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::integrations::deps::GetWatchQueueDeps;
|
||||||
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
|
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
|
||||||
|
|
||||||
|
fn deps(events: &Arc<InMemoryWatchEventRepository>) -> GetWatchQueueDeps {
|
||||||
|
GetWatchQueueDeps {
|
||||||
|
watch_event_query: Arc::clone(events) as _,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_when_no_events() {
|
async fn returns_empty_when_no_events() {
|
||||||
let events = InMemoryWatchEventRepository::new();
|
let events = InMemoryWatchEventRepository::new();
|
||||||
|
|
||||||
let result = get_queue::execute(
|
let result = get_queue::execute(
|
||||||
Arc::clone(&events) as _,
|
&deps(&events),
|
||||||
GetWatchQueueQuery {
|
GetWatchQueueQuery {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
},
|
},
|
||||||
@@ -41,7 +48,7 @@ async fn returns_pending_events() {
|
|||||||
);
|
);
|
||||||
events.save(&event).await.unwrap();
|
events.save(&event).await.unwrap();
|
||||||
|
|
||||||
let result = get_queue::execute(Arc::clone(&events) as _, GetWatchQueueQuery { user_id })
|
let result = get_queue::execute(&deps(&events), GetWatchQueueQuery { user_id })
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,30 @@ use domain::ports::WebhookTokenRepository;
|
|||||||
use domain::testing::InMemoryWebhookTokenRepository;
|
use domain::testing::InMemoryWebhookTokenRepository;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps};
|
||||||
use crate::integrations::{
|
use crate::integrations::{
|
||||||
commands::GenerateWebhookTokenCommand, generate_token, get_tokens,
|
commands::GenerateWebhookTokenCommand, generate_token, get_tokens,
|
||||||
queries::GetWebhookTokensQuery,
|
queries::GetWebhookTokensQuery,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn generate_deps(tokens: &Arc<dyn WebhookTokenRepository>) -> GenerateWebhookTokenDeps {
|
||||||
|
GenerateWebhookTokenDeps {
|
||||||
|
webhook_token: Arc::clone(tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_deps(tokens: &Arc<dyn WebhookTokenRepository>) -> GetWebhookTokensDeps {
|
||||||
|
GetWebhookTokensDeps {
|
||||||
|
webhook_token: Arc::clone(tokens),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn returns_empty_when_no_tokens() {
|
async fn returns_empty_when_no_tokens() {
|
||||||
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
|
||||||
|
|
||||||
let result = get_tokens::execute(
|
let result = get_tokens::execute(
|
||||||
Arc::clone(&tokens),
|
&get_deps(&tokens),
|
||||||
GetWebhookTokensQuery {
|
GetWebhookTokensQuery {
|
||||||
user_id: Uuid::new_v4(),
|
user_id: Uuid::new_v4(),
|
||||||
},
|
},
|
||||||
@@ -33,7 +46,7 @@ async fn returns_tokens_after_generate() {
|
|||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
generate_token::execute(
|
generate_token::execute(
|
||||||
Arc::clone(&tokens),
|
&generate_deps(&tokens),
|
||||||
GenerateWebhookTokenCommand {
|
GenerateWebhookTokenCommand {
|
||||||
user_id,
|
user_id,
|
||||||
provider: WatchEventSource::Jellyfin,
|
provider: WatchEventSource::Jellyfin,
|
||||||
@@ -44,7 +57,7 @@ async fn returns_tokens_after_generate() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
generate_token::execute(
|
generate_token::execute(
|
||||||
Arc::clone(&tokens),
|
&generate_deps(&tokens),
|
||||||
GenerateWebhookTokenCommand {
|
GenerateWebhookTokenCommand {
|
||||||
user_id,
|
user_id,
|
||||||
provider: WatchEventSource::Plex,
|
provider: WatchEventSource::Plex,
|
||||||
@@ -54,7 +67,7 @@ async fn returns_tokens_after_generate() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
|
let result = get_tokens::execute(&get_deps(&tokens), GetWebhookTokensQuery { user_id })
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use domain::testing::{
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand};
|
use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand};
|
||||||
use crate::integrations::deps::IngestWatchEventDeps;
|
use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps};
|
||||||
use crate::integrations::{generate_token, ingest};
|
use crate::integrations::{generate_token, ingest};
|
||||||
|
|
||||||
struct FakeParser;
|
struct FakeParser;
|
||||||
@@ -35,7 +35,9 @@ async fn ingests_watch_event() {
|
|||||||
|
|
||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
let generated = generate_token::execute(
|
let generated = generate_token::execute(
|
||||||
Arc::clone(&tokens),
|
&GenerateWebhookTokenDeps {
|
||||||
|
webhook_token: Arc::clone(&tokens),
|
||||||
|
},
|
||||||
GenerateWebhookTokenCommand {
|
GenerateWebhookTokenCommand {
|
||||||
user_id,
|
user_id,
|
||||||
provider: WatchEventSource::Jellyfin,
|
provider: WatchEventSource::Jellyfin,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ use domain::ports::WebhookTokenRepository;
|
|||||||
use domain::testing::InMemoryWebhookTokenRepository;
|
use domain::testing::InMemoryWebhookTokenRepository;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::integrations::deps::{
|
||||||
|
GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps,
|
||||||
|
};
|
||||||
use crate::integrations::{
|
use crate::integrations::{
|
||||||
commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand},
|
commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand},
|
||||||
generate_token, get_tokens,
|
generate_token, get_tokens,
|
||||||
@@ -19,7 +22,9 @@ async fn revokes_existing_token() {
|
|||||||
let user_id = Uuid::new_v4();
|
let user_id = Uuid::new_v4();
|
||||||
|
|
||||||
let generated = generate_token::execute(
|
let generated = generate_token::execute(
|
||||||
Arc::clone(&tokens),
|
&GenerateWebhookTokenDeps {
|
||||||
|
webhook_token: Arc::clone(&tokens),
|
||||||
|
},
|
||||||
GenerateWebhookTokenCommand {
|
GenerateWebhookTokenCommand {
|
||||||
user_id,
|
user_id,
|
||||||
provider: WatchEventSource::Jellyfin,
|
provider: WatchEventSource::Jellyfin,
|
||||||
@@ -32,15 +37,22 @@ async fn revokes_existing_token() {
|
|||||||
let token_id = generated.token.id().value();
|
let token_id = generated.token.id().value();
|
||||||
|
|
||||||
revoke_token::execute(
|
revoke_token::execute(
|
||||||
Arc::clone(&tokens),
|
&RevokeWebhookTokenDeps {
|
||||||
|
webhook_token: Arc::clone(&tokens),
|
||||||
|
},
|
||||||
RevokeWebhookTokenCommand { user_id, token_id },
|
RevokeWebhookTokenCommand { user_id, token_id },
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let remaining = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
|
let remaining = get_tokens::execute(
|
||||||
.await
|
&GetWebhookTokensDeps {
|
||||||
.unwrap();
|
webhook_token: Arc::clone(&tokens),
|
||||||
|
},
|
||||||
|
GetWebhookTokensQuery { user_id },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(remaining.is_empty());
|
assert!(remaining.is_empty());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
|||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end,
|
end_date: end,
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::wrapup::generate::execute(
|
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
|
||||||
self.wrapup_repo.clone(),
|
wrapup_repo: self.wrapup_repo.clone(),
|
||||||
self.event_publisher.clone(),
|
event_publisher: self.event_publisher.clone(),
|
||||||
cmd,
|
};
|
||||||
)
|
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
"auto-generate wrapup for user {} failed: {e}",
|
"auto-generate wrapup for user {} failed: {e}",
|
||||||
user.user_id.value()
|
user.user_id.value()
|
||||||
@@ -81,13 +79,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
|
|||||||
start_date: start,
|
start_date: start,
|
||||||
end_date: end,
|
end_date: end,
|
||||||
};
|
};
|
||||||
if let Err(e) = crate::wrapup::generate::execute(
|
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
|
||||||
self.wrapup_repo.clone(),
|
wrapup_repo: self.wrapup_repo.clone(),
|
||||||
self.event_publisher.clone(),
|
event_publisher: self.event_publisher.clone(),
|
||||||
cmd,
|
};
|
||||||
)
|
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!("auto-generate global wrapup failed: {e}");
|
tracing::warn!("auto-generate global wrapup failed: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod deps;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod ports;
|
pub mod ports;
|
||||||
|
pub mod services;
|
||||||
pub mod worker;
|
pub mod worker;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
@@ -19,6 +21,13 @@ pub mod wrapup;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub mod test_helpers;
|
pub mod test_helpers;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "tests/services.rs"]
|
||||||
|
mod services_tests;
|
||||||
|
|
||||||
|
pub use deps::Deps;
|
||||||
|
pub use deps::{WorkerDeps, WorkerServices};
|
||||||
pub use movies::MovieDiscoveryIndexer;
|
pub use movies::MovieDiscoveryIndexer;
|
||||||
pub use movies::SearchCleanupHandler;
|
pub use movies::SearchCleanupHandler;
|
||||||
pub use movies::SearchReindexHandler;
|
pub use movies::SearchReindexHandler;
|
||||||
|
pub use services::Services;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user