structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -45,7 +45,9 @@ ALLOW_REGISTRATION=true
# PORT=3000
# RATE_LIMIT=60
# SECURE_COOKIES=true
# RUST_LOG=presentation=info,tower_http=info,worker=info
# Handler-level logs come from the `presentation` crate; startup/wiring logs come
# from `server`. Include both — `server=info` alone silently drops handler logs.
# RUST_LOG=server=info,presentation=info,tower_http=info,worker=info
# CORS (for SPA development only)
# CORS_ORIGINS=http://localhost:5173

View File

@@ -40,3 +40,8 @@ jobs:
- name: test
run: cargo test
# Layering guards. These live in the Makefile and were previously local-only,
# so a PR could go green in CI while violating them.
- name: guards
run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free

View File

@@ -41,6 +41,11 @@ jobs:
- name: test
run: cargo test
# Layering guards. These live in the Makefile and were previously local-only,
# so a PR could go green in CI while violating them.
- name: guards
run: make check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
docker:
name: Build & Push Docker Image
runs-on: ubuntu-latest

2
.gitignore vendored
View File

@@ -13,7 +13,7 @@
.worktrees/
.superpowers/
docs/
docs/*
!docs/adr/
imgs/

View File

@@ -10,7 +10,7 @@ Thanks for your interest in Movies Diary! This is a personal project but contrib
4. Run the backend and worker:
```bash
cargo run -p presentation # HTTP server on :3000
cargo run -p server # HTTP server on :3000
cargo run -p worker # event worker (separate terminal)
```
@@ -44,7 +44,7 @@ The project follows hexagonal (ports & adapters) architecture. See `architecture
**Key rules:**
- Presentation handlers never touch repositories directly — all domain logic goes through use cases in the `application` crate
- Application use cases return raw domain data — URL formatting, date display, and view model assembly belong in presentation mappers (`presentation/src/mappers/`)
- Use cases called from presentation handlers take `&AppContext`. Functions called from adapter event handlers take individual `Arc<dyn Trait>` params to keep adapter dependencies explicit
- Use cases called from presentation handlers take a `&FooDeps` struct (registered in `application::Deps`, built by `composition::build_deps`) — never `&AppContext` itself, which `application` cannot even depend on. A few keep individual `Arc<dyn Trait>` params instead: `enrich_movie` and `request_enrichment` because they are called from adapters rather than handlers, and `diary::log_review` because its one extra dependency comes from `Services` rather than a repository. `wrapup::compute`, `import::cleanup` and `integrations::cleanup` also take individual params, but they are only ever called from jobs. See ADR-0007 for the exact list and why each is legitimate
```
domain → pure types, traits (ports), zero deps

134
Cargo.lock generated
View File

@@ -306,6 +306,7 @@ version = "0.1.0"
dependencies = [
"domain",
"serde",
"serde_json",
"utoipa",
"uuid",
]
@@ -1118,6 +1119,39 @@ dependencies = [
"static_assertions",
]
[[package]]
name = "composition"
version = "0.1.0"
dependencies = [
"activitypub",
"anyhow",
"application",
"async-trait",
"auth",
"chrono",
"domain",
"infra-wiring",
"jellyfin",
"metadata",
"nats",
"object-storage",
"plex",
"poster-fetcher",
"postgres",
"postgres-event-queue",
"postgres-federation",
"postgres-search",
"postgres-social",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlite-search",
"sqlite-social",
"sqlx",
"tokio",
"uuid",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -1621,6 +1655,7 @@ dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"uuid",
]
@@ -3905,7 +3940,7 @@ dependencies = [
"chrono",
"domain",
"futures",
"postgres-federation",
"postgres-social",
"serde",
"serde_json",
"sqlx",
@@ -3940,6 +3975,7 @@ dependencies = [
"chrono",
"domain",
"k-ap",
"postgres-social",
"serde_json",
"sqlx",
"tracing",
@@ -3957,6 +3993,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "postgres-social"
version = "0.1.0"
dependencies = [
"adapter-common",
"async-trait",
"chrono",
"domain",
"sqlx",
"uuid",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
@@ -3991,43 +4039,22 @@ checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
name = "presentation"
version = "0.1.0"
dependencies = [
"activitypub",
"anyhow",
"api-types",
"application",
"async-trait",
"auth",
"axum",
"axum-governor",
"bytes",
"chrono",
"composition",
"domain",
"dotenvy",
"export",
"futures",
"http-body-util",
"importer",
"infer",
"infra-wiring",
"jellyfin",
"metadata",
"nats",
"object-storage",
"percent-encoding",
"plex",
"poster-fetcher",
"postgres",
"postgres-event-queue",
"postgres-federation",
"postgres-search",
"rss 0.1.0",
"serde",
"serde_json",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlite-search",
"sqlx",
"template-askama",
"tokio",
"tower",
@@ -4947,6 +4974,50 @@ dependencies = [
"serde",
]
[[package]]
name = "server"
version = "0.1.0"
dependencies = [
"activitypub",
"anyhow",
"application",
"async-trait",
"auth",
"axum",
"bytes",
"composition",
"domain",
"dotenvy",
"export",
"futures",
"http-body-util",
"importer",
"infra-wiring",
"metadata",
"nats",
"object-storage",
"poster-fetcher",
"postgres",
"postgres-event-queue",
"postgres-federation",
"postgres-search",
"postgres-social",
"presentation",
"rss 0.1.0",
"serde_json",
"sqlite",
"sqlite-event-queue",
"sqlite-federation",
"sqlite-search",
"sqlite-social",
"sqlx",
"tokio",
"tower",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "sha1"
version = "0.10.6"
@@ -5190,7 +5261,7 @@ dependencies = [
"futures",
"serde",
"serde_json",
"sqlite-federation",
"sqlite-social",
"sqlx",
"tokio",
"tracing",
@@ -5224,6 +5295,7 @@ dependencies = [
"domain",
"k-ap",
"serde_json",
"sqlite-social",
"sqlx",
"tokio",
"tracing",
@@ -5242,6 +5314,19 @@ dependencies = [
"uuid",
]
[[package]]
name = "sqlite-social"
version = "0.1.0"
dependencies = [
"adapter-common",
"async-trait",
"chrono",
"domain",
"sqlx",
"tokio",
"uuid",
]
[[package]]
name = "sqlx"
version = "0.8.6"
@@ -7151,6 +7236,7 @@ dependencies = [
"application",
"async-trait",
"auth",
"composition",
"domain",
"dotenvy",
"export",

View File

@@ -10,7 +10,9 @@ members = [
"crates/adapters/sqlite",
"crates/adapters/postgres",
"crates/adapters/sqlite-federation",
"crates/adapters/sqlite-social",
"crates/adapters/postgres-federation",
"crates/adapters/postgres-social",
"crates/adapters/sqlite-event-queue",
"crates/adapters/postgres-event-queue",
"crates/adapters/template-askama",
@@ -23,7 +25,9 @@ members = [
"crates/adapters/tmdb-enrichment",
"crates/adapters/image-converter",
"crates/domain",
"crates/composition",
"crates/presentation",
"crates/server",
"crates/tui",
"crates/worker",
"crates/adapters/importer",
@@ -68,6 +72,7 @@ api-types = { path = "crates/api-types" }
domain = { path = "crates/domain" }
tmdb-enrichment = { path = "crates/adapters/tmdb-enrichment" }
application = { path = "crates/application" }
composition = { path = "crates/composition" }
presentation = { path = "crates/presentation" }
auth = { path = "crates/adapters/auth" }
metadata = { path = "crates/adapters/metadata" }
@@ -79,8 +84,10 @@ rss = { path = "crates/adapters/rss" }
export = { path = "crates/adapters/export" }
sqlite = { path = "crates/adapters/sqlite" }
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
sqlite-social = { path = "crates/adapters/sqlite-social" }
postgres = { path = "crates/adapters/postgres" }
postgres-federation = { path = "crates/adapters/postgres-federation" }
postgres-social = { path = "crates/adapters/postgres-social" }
template-askama = { path = "crates/adapters/template-askama" }
activitypub = { path = "crates/adapters/activitypub" }
event-payload = { path = "crates/adapters/event-payload" }

View File

@@ -12,6 +12,10 @@ FROM rust:slim-bookworm AS builder
WORKDIR /build
# Cache dependency compilation separately from source
#
# Every workspace member's Cargo.toml must be listed here by hand — `cargo fetch`
# below reads the whole workspace graph, so a missing manifest fails the build with
# "failed to read .../Cargo.toml". Adding a crate to crates/ means adding a line here.
COPY Cargo.toml Cargo.lock ./
COPY .cargo ./.cargo
COPY crates/adapters/activitypub/Cargo.toml crates/adapters/activitypub/Cargo.toml
@@ -30,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/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
COPY crates/adapters/sqlite-federation/Cargo.toml crates/adapters/sqlite-federation/Cargo.toml
COPY crates/adapters/sqlite-social/Cargo.toml crates/adapters/sqlite-social/Cargo.toml
COPY crates/adapters/sqlite-event-queue/Cargo.toml crates/adapters/sqlite-event-queue/Cargo.toml
COPY crates/adapters/postgres/Cargo.toml crates/adapters/postgres/Cargo.toml
COPY crates/adapters/postgres-federation/Cargo.toml crates/adapters/postgres-federation/Cargo.toml
COPY crates/adapters/postgres-social/Cargo.toml crates/adapters/postgres-social/Cargo.toml
COPY crates/adapters/postgres-event-queue/Cargo.toml crates/adapters/postgres-event-queue/Cargo.toml
COPY crates/adapters/template-askama/Cargo.toml crates/adapters/template-askama/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/adapters/tmdb-enrichment/Cargo.toml crates/adapters/tmdb-enrichment/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/composition/Cargo.toml crates/composition/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/server/Cargo.toml crates/server/Cargo.toml
COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
@@ -71,7 +79,7 @@ COPY crates ./crates
# To add NATS support (EVENT_BUS_BACKEND=nats):
# --build-arg FEATURES=sqlite,sqlite-federation,nats
ARG FEATURES=sqlite,sqlite-federation
RUN cargo build --release -p presentation -p worker --no-default-features --features "${FEATURES}"
RUN cargo build --release -p server -p worker --no-default-features --features "${FEATURES}"
# ----- runtime -----
FROM debian:bookworm-slim
@@ -85,13 +93,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app
COPY --from=builder /build/target/release/presentation ./presentation
COPY --from=builder /build/target/release/server ./server
COPY --from=builder /build/target/release/worker ./worker
COPY static ./static
COPY --from=spa-builder /spa/dist ./spa/dist
EXPOSE 3000
ENV RUST_LOG=presentation=info,tower_http=info
ENV RUST_LOG=server=info,tower_http=info
CMD ["./presentation"]
CMD ["./server"]

160
Makefile
View File

@@ -1,7 +1,7 @@
.DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would.
check: fmt-check clippy test check-appcontext
check: fmt-check clippy test check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free
@echo "✅ All checks passed"
# Enforce that no application use case imports AppContext (god-object guard).
@@ -13,6 +13,162 @@ check-appcontext:
fi
@echo "✅ No AppContext in application crate"
# Enforce that presentation never hand-builds a deps struct (composition-root guard).
# Catches struct literals (`FooDeps { ... }`) and conversions (`FooDeps::from(...)`) —
# both are the composition-root pattern that belongs in crates/composition.
#
# Scope is the whole crate, not just handlers/, so that factoring a
# `fn build_login_deps(state) -> LoginDeps { .. }` helper into a sibling module
# does not slip past the guard. One exclusion:
# src/tests/ — test fixtures legitimately assemble their own state
# `src/main.rs` used to be excluded too ("the binary IS the wiring root") before
# crates/server took over the binary (ADR-0006) — presentation is lib-only now, has no
# main.rs, and the exclusion was dropped rather than left pointing at a file that no
# longer exists.
# NOTE: `\s` is a GNU grep extension, not POSIX ERE. Fine on GNU/Ubuntu runners;
# would need `[[:space:]]` if this ever runs under BusyBox/Alpine grep.
check-handler-deps:
@if grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
echo "❌ hand-built deps struct found in presentation:"; \
grep -rnE "[A-Za-z0-9_]*Deps(\s*\{|::from\()" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
exit 1; \
fi
@echo "✅ No hand-built deps structs in presentation"
# Enforce that presentation calls use cases only, never repository methods directly
# (ADR-0006's guard; ADR-0007 records the widening below).
#
# Was: two shape-matching grep passes for `repos.<field>.<method>(` and
# `repos.<field>.clone().<method>(`, needed because `AppContext.repos` was still a
# legitimate way to reach a repository — ~40 sites cloned an Arc out of it to pass
# positionally into a use case, so the guard had to distinguish that legal shape
# from an illegal direct call. That shape-matching was demonstrably incomplete: it
# matches `repos.<field>.<method>(` textually, so it cannot see a bare reference
# pass (`&repos.diary`, no call at all at the read site) or a `let`-bound access
# (`if let Some(x) = repos.federated_profile` then `x.method()` on the *binding*,
# not on `repos` itself). Both shapes existed in production code — Plan C2's Task 6
# review caught it, Task 7 fixed both — while the two-pass guard printed a clean
# ✅ the whole time. ADR-0006's "handlers call use cases only" was false at that
# line for as long as the hole existed.
#
# Now: Plan C2 deleted `AppContext.repos` entirely (ADR-0007), so there is no
# longer any legitimate reason for the substring `app_ctx.repos` to appear in
# production code — not a call, not a clone, not a reference, not a let-binding.
# That makes the rule trivial and unevadeable: any mention at all is an offender.
# No shape to match means no shape to miss.
#
# One exclusion: src/tests/ — test helpers still legitimately build a
# `Repositories` and call `composition::build_deps` to produce test state.
check-handler-repos:
@if grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests | grep -q .; then \
echo "❌ app_ctx.repos referenced in presentation:"; \
grep -rn "app_ctx\.repos" crates/presentation/src --include="*.rs" --exclude-dir=tests; \
exit 1; \
fi
@echo "✅ No app_ctx.repos references in presentation"
# Enforce that presentation depends on at most one adapter crate.
#
# Presentation may name things that RENDER OUTPUT; it may not name things that
# REACH external systems or storage. `template-askama` is the HTML template
# engine, so it stays. See ADR-0008.
#
# This checks Cargo.toml rather than grepping src/, deliberately: `<crate>::`
# greps collide with same-named modules inside `application` and `presentation`
# (`auth::logout` is `application::auth`; `rss::get_user_feed` is
# `handlers::rss`), so the source-level grep cannot decide this. Every directory
# under crates/adapters/ is named exactly like its package, so directory names
# are a safe source of truth.
PRESENTATION_ALLOWED_ADAPTERS := template-askama
check-presentation-adapters:
@deps=$$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f' crates/presentation/Cargo.toml \
| sed -n 's/^\([A-Za-z0-9_-]\{1,\}\)[[:space:]]*=.*/\1/p'); \
bad=""; \
for a in $$(ls crates/adapters); do \
case " $(PRESENTATION_ALLOWED_ADAPTERS) " in *" $$a "*) continue;; esac; \
if echo "$$deps" | grep -qx "$$a"; then bad="$$bad $$a"; fi; \
done; \
if [ -n "$$bad" ]; then \
echo "❌ presentation depends on adapter crate(s):$$bad"; \
echo " presentation may name renderers, not reachers — see ADR-0008."; \
exit 1; \
fi; \
echo "✅ presentation depends on no adapter crate but $(PRESENTATION_ALLOWED_ADAPTERS)"
# Enforce that the federation-off build wires real social behavior, not silent noops.
#
# With federation off, `NoopSocialCommand::follow` returns Ok(()) and writes nothing,
# and `NoopSocialQuery` always answers zero/empty. The write side is currently
# unreachable over HTTP (the social routes are federation-gated in presentation), but
# the query side is not: `get_local_profile`, `get_page_viewer` and `get_activity_feed`
# read it through fully ungated routes, so a noop there means a federation-off
# instance lies about its own follow graph on every profile view and activity feed
# request — contradicting ADR-0003, which established that local follows bypass
# ActivityPub entirely. `application::social::LocalSocialService` is what must be
# wired instead. See ADR-0009.
#
# Two checks, not one: absence of the noops is necessary but not sufficient — deleting
# the whole `#[cfg(not(feature = "federation"))]` wiring block would also make the
# first check pass, leaving federation-off with nothing wired at all, which is worse
# than the noops. So this also asserts `LocalSocialService` is present — and it must
# be present in the federation-OFF region specifically, not merely anywhere in the
# file: `LocalSocialService` is also constructed inside the federation-ON branch
# (feeding `CompositeSocialAdapter`), so a bare file-wide grep is satisfied by that
# occurrence alone and would not catch the federation-off block being deleted. The
# awk carves out the region from the first `#[cfg(not(feature = "federation"))]` to
# the next `#[cfg(feature = "federation")]` — the same kind of range
# `check-presentation-adapters` uses for `[dependencies]` — and greps only inside it.
# Not an occurrence count: that would work today but break the moment the two
# `LocalSocialService` constructions in `main.rs` are deduplicated (a recorded
# follow-up), which could legitimately drop the total to one.
#
# A grep rather than a test because the decision lives in `wire_dependencies()`, a
# private fn in a binary crate, unreachable from integration tests.
#
# Scope is the single file `crates/server/src/main.rs`, not the whole crate: it is the
# sole wiring site for these ports. `worker` and `tui` never name SocialCommand /
# FollowGraphQuery / BlockQuery at all, so there is nowhere else for the noops to
# reappear. Relocating the wiring out of main.rs would move it out of the guard's
# scope — widen this to `crates/server/src` if that ever happens.
check-federation-off-social:
@if grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs | grep -q .; then \
echo "❌ server wires social noops — federation-off would report an empty follow graph:"; \
grep -n "NoopSocialCommand\|NoopSocialQuery" crates/server/src/main.rs; \
exit 1; \
fi
@if ! awk '/cfg\(not\(feature = "federation"\)\)/ && !started {started=1} started && /cfg\(feature = "federation"\)/ {exit} started {print}' crates/server/src/main.rs | grep -q "LocalSocialService"; then \
echo "❌ LocalSocialService not wired in the federation-off region of crates/server/src/main.rs — federation-off has no social behavior wired at all, not even noops"; \
exit 1; \
fi
@echo "✅ server wires real social behavior in every configuration"
# Enforce that `<backend>-social` crates never depend on ActivityPub.
#
# ADR-0009's whole federation-optional property rests on one invariant: a
# `<backend>-social` crate speaks only `domain::ports`; a `<backend>-federation` crate
# speaks ActivityPub; the former must never depend on the latter. It holds today, and
# nothing but this guard enforces it. One manifest line —
# `k-ap = { version = "0.5.0", registry = "gitea" }` in a `-social` crate's Cargo.toml,
# the obvious move when someone wants to reuse a helper like `status_to_str` from
# `sqlite-federation/src/lib.rs` — would silently restore all 76 crates and 19MB that
# federation-off exists to shed, while `make check`, `cargo test` and CI all stay green
# without this guard to catch it. Matches `activitypub` with any suffix, so
# `activitypub_federation = "0.6"` (the upstream crate carrying all 76 transitives) is
# caught too, not just the local `activitypub` crate.
#
# Known gap: a dependency renamed via a `package = "k-ap"` key (`foo = { package =
# "k-ap", ... }`), or declared with a `[dependencies.k-ap]` table header instead of the
# inline form, would not match this line-level regex. Not attempted — nothing in the
# workspace does either today.
check-social-crates-are-ap-free:
@if grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml | grep -q .; then \
echo "❌ social crate depends on ActivityPub:"; \
grep -nE '^[[:space:]]*(k-ap|activitypub[A-Za-z0-9_-]*|[A-Za-z0-9_-]+-federation)[[:space:]]*=' crates/adapters/*-social/Cargo.toml; \
exit 1; \
fi
@echo "✅ social crates depend on no k-ap, activitypub, or *-federation crate"
# Apply rustfmt to all files.
fmt:
cargo fmt
@@ -34,4 +190,4 @@ fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix
.PHONY: check fmt fmt-check clippy test fix check-appcontext check-handler-deps check-handler-repos check-presentation-adapters check-federation-off-social check-social-crates-are-ap-free

View File

@@ -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
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
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)
adapters/
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 |
| `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
| `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
| `RUST_LOG` | — | No | Log verbosity (e.g. `server=info,worker=info`) |
| `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
| `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
| `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
The `worker` binary must run alongside `presentation` to process events:
The `worker` binary must run alongside `server` to process events:
```bash
cargo run -p worker
@@ -173,11 +174,11 @@ cargo run -p worker
## Run
```bash
cargo run -p presentation # HTTP server (0.0.0.0:3000)
cargo run -p server # HTTP server (0.0.0.0:3000)
cargo run -p worker # event worker (poster sync, in a separate terminal)
```
The worker polls the event queue and must run alongside the presentation to process background tasks like poster fetching. Both processes share the same database.
The worker polls the event queue and must run alongside the server to process background tasks like poster fetching. Both processes share the same database.
## API
@@ -249,7 +250,7 @@ This builds and starts the HTTP server (port 3000) and event worker. Data is per
### Manual docker run
The image contains both `presentation` and `worker` binaries. Run them as separate containers sharing the same data volume:
The image contains both `server` and `worker` binaries. Run them as separate containers sharing the same data volume:
```bash
docker build -t movies-diary .

View File

@@ -8,7 +8,7 @@ use domain::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{MovieId, ReviewId, UserId},
value_objects::{InstanceIdentity, MovieId, ReviewId, UserId},
};
use std::sync::Arc;
@@ -25,7 +25,7 @@ pub struct ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
instance: InstanceIdentity,
}
impl ActivityPubEventHandler {
@@ -38,7 +38,7 @@ impl ActivityPubEventHandler {
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
instance: InstanceIdentity,
) -> Self {
Self {
ap_service,
@@ -48,7 +48,7 @@ impl ActivityPubEventHandler {
goal_repo,
stats_repo,
federation_settings,
base_url,
instance,
}
}
}
@@ -140,7 +140,7 @@ impl EventHandler for ActivityPubEventHandler {
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string())),
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
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await
@@ -179,8 +179,8 @@ impl ActivityPubEventHandler {
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
@@ -210,8 +210,8 @@ impl ActivityPubEventHandler {
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
base_url: self.base_url.clone(),
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
@@ -245,8 +245,8 @@ impl ActivityPubEventHandler {
None => return Ok(()),
};
let ap_id = review_url(&self.base_url, review_id);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review_id);
let actor = actor_url(&self.instance, user_id.value());
let movie = self
.movie_repo
@@ -276,8 +276,8 @@ impl ActivityPubEventHandler {
poster_url: movie
.as_ref()
.and_then(|m| m.poster_path())
.map(|p| format!("{}/images/{}", self.base_url, p.value())),
base_url: self.base_url.clone(),
.map(|p| self.instance.image_url_for(p.value())),
base_url: self.instance.base_url().to_string(),
},
);
let json = serde_json::to_value(obj)?;
@@ -294,7 +294,7 @@ impl ActivityPubEventHandler {
user_id: &UserId,
review_id: &ReviewId,
) -> anyhow::Result<()> {
let ap_id = review_url(&self.base_url, review_id);
let ap_id = review_url(&self.instance, review_id);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
@@ -320,8 +320,8 @@ impl ActivityPubEventHandler {
}
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
let actor = actor_url(&self.instance, user_id.value());
let poster_url = self
.movie_repo
@@ -331,7 +331,7 @@ impl ActivityPubEventHandler {
.flatten()
.and_then(|m| {
m.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()))
.map(|p| self.instance.image_url_for(p.value()))
});
let added_at_utc =
@@ -344,7 +344,7 @@ impl ActivityPubEventHandler {
external_metadata_id: external_metadata_id.clone(),
poster_url,
added_at: added_at_utc,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
});
let json = serde_json::to_value(obj)?;
@@ -360,7 +360,7 @@ impl ActivityPubEventHandler {
movie_id: &domain::value_objects::MovieId,
) -> anyhow::Result<()> {
use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let ap_id = watchlist_entry_url(&self.instance, user_id.value(), movie_id.value());
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;
@@ -383,7 +383,7 @@ impl ActivityPubEventHandler {
.map(|id| id.value().to_string());
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
for entry in entries {
let review = entry.review();
@@ -398,8 +398,8 @@ impl ActivityPubEventHandler {
continue;
}
let ap_id = review_url(&self.base_url, review.id());
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = review_url(&self.instance, review.id());
let actor = actor_url(&self.instance, user_id.value());
let obj = review_to_ap_object(
review,
@@ -410,7 +410,7 @@ impl ActivityPubEventHandler {
release_year: movie.release_year().value(),
external_metadata_id: external_metadata_id.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)?;
@@ -450,15 +450,15 @@ impl ActivityPubEventHandler {
.count_reviews_in_year(user_id, year)
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
goal.target_count(),
current,
&self.base_url,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
self.ap_service
@@ -488,9 +488,16 @@ impl ActivityPubEventHandler {
.await
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let actor = actor_url(&self.base_url, user_id.value());
let obj = goal_to_ap_object(ap_id, actor, year, target_count, current, &self.base_url);
let ap_id = goal_url(&self.instance, user_id.value(), year);
let actor = actor_url(&self.instance, user_id.value());
let obj = goal_to_ap_object(
ap_id,
actor,
year,
target_count,
current,
self.instance.base_url(),
);
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
@@ -513,7 +520,7 @@ impl ActivityPubEventHandler {
if !flags.goals {
return Ok(());
}
let ap_id = goal_url(&self.base_url, user_id.value(), year);
let ap_id = goal_url(&self.instance, user_id.value(), year);
self.ap_service
.broadcast_delete_to_followers(user_id.value(), ap_id)
.await?;

View 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)
}
}

View File

@@ -5,7 +5,7 @@ use chrono::DateTime;
use domain::{
models::RemoteGoalEntry,
ports::{GoalQuery, RemoteGoalRepository},
value_objects::UserId,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
@@ -16,7 +16,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub goal_repo: Arc<dyn GoalQuery>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -34,11 +34,11 @@ impl ApContentReader for GoalObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
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 obj = goal_to_ap_object(
ap_id.clone(),
@@ -46,7 +46,7 @@ impl ApContentReader for GoalObjectHandler {
goal.year(),
goal.target_count(),
0,
&self.base_url,
self.instance.base_url(),
);
results.push(LocalObject {
ap_id,

View File

@@ -1,9 +1,9 @@
pub mod composite_handler;
pub mod event_handler;
pub mod federation_event_bridge;
pub mod federation_ports;
pub mod goal_handler;
pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
@@ -22,7 +22,7 @@ pub use k_ap::{
};
pub use event_handler::ActivityPubEventHandler;
pub use port::{ActivityPubPort, NoopActivityPubService};
pub use federation_ports::ApServiceAdapter;
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter;
@@ -41,7 +41,16 @@ pub struct FederationRepos {
}
pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>,
/// AP document serving. Prefer this over `service` from outside this crate.
pub document: std::sync::Arc<dyn domain::ports::ApDocumentPort>,
/// Instance domain blocklist. Prefer this over `service` from outside this crate.
pub blocklist: std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
/// Post-follow content backfill. Prefer this over `service` from outside this crate.
pub backfill: std::sync::Arc<dyn domain::ports::ApBackfillPort>,
/// The concrete service, consumed by `crates/server` to construct
/// `CompositeSocialAdapter`. Everything else should use
/// `document`/`blocklist`/`backfill`.
pub service: std::sync::Arc<ActivityPubService>,
pub router: axum::Router,
pub event_handler: std::sync::Arc<dyn domain::ports::EventHandler>,
}
@@ -64,7 +73,7 @@ pub struct ActivityPubDeps {
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
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 event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
}
@@ -88,7 +97,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
federation_settings,
follow_command: _,
follow_query: _,
base_url,
instance,
allow_registration,
event_publisher,
} = deps;
@@ -98,17 +107,17 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
diary_repo,
review_store,
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 {
remote_watchlist_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
base_url: base_url.clone(),
instance: instance.clone(),
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo,
goal_repo: std::sync::Arc::clone(&goal_repo),
base_url: base_url.clone(),
instance: instance.clone(),
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
review: review_handler,
@@ -132,14 +141,14 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
);
let concrete = std::sync::Arc::new(
ActivityPubService::builder(base_url.clone())
ActivityPubService::builder(instance.base_url().to_string())
.activity_repo(activity_repo)
.follow_repo(follow_repo)
.actor_repo(actor_repo)
.blocklist_repo(blocklist_repo)
.user_repo(std::sync::Arc::new(DomainUserRepoAdapter::new(
user_repo,
base_url.clone(),
instance.clone(),
)))
.signed_fetch_actor_id(INSTANCE_ACTOR_ID)
.content_reader(composite.clone() as std::sync::Arc<dyn ApContentReader>)
@@ -165,11 +174,20 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
goal_repo,
stats_repo,
federation_settings,
base_url,
instance,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;
let ports = std::sync::Arc::new(federation_ports::ApServiceAdapter::new(
std::sync::Arc::clone(&concrete),
));
Ok(ActivityPubWire {
service: concrete as std::sync::Arc<dyn ActivityPubPort>,
document: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::ApDocumentPort>,
blocklist: std::sync::Arc::clone(&ports)
as std::sync::Arc<dyn domain::ports::InstanceBlocklistPort>,
backfill: ports as std::sync::Arc<dyn domain::ports::ApBackfillPort>,
service: concrete,
router,
event_handler,
})

View File

@@ -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(())
}
}

View File

@@ -5,7 +5,9 @@ use domain::{
events::DomainEvent,
models::ReviewSource,
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 url::Url;
@@ -20,7 +22,7 @@ pub struct ReviewObjectHandler {
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -39,17 +41,17 @@ impl ApContentReader for ReviewObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let mut results = Vec::new();
for entry in entries {
let review = entry.review();
let published =
chrono::DateTime::from_naive_utc_and_offset(*review.watched_at(), chrono::Utc);
let movie = entry.movie();
let ap_id = review_url(&self.base_url, review.id());
let ap_id = review_url(&self.instance, review.id());
let poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
let obj = review_to_ap_object(
review,
@@ -62,7 +64,7 @@ impl ApContentReader for ReviewObjectHandler {
.external_metadata_id()
.map(|id| id.value().to_string()),
poster_url,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
},
);
let follower_cc = format!("{}/followers", actor);

View File

@@ -3,73 +3,33 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::{
errors::DomainError,
ports::{FollowCommand, FollowQuery, SocialCommand, SocialQuery, UserRepository},
value_objects::{FollowStatus, FollowTarget, SocialActor, SocialIdentity, UserId, Username},
ports::{BlockQuery, FollowGraphQuery, LocalSocial, SocialCommand, UserRepository},
value_objects::{
FollowRelation, FollowTarget, InstanceIdentity, SocialActor, SocialIdentity, UserId,
},
};
use super::ActivityPubPort;
use k_ap::ActivityPubService;
pub struct CompositeSocialAdapter {
ap_service: Arc<dyn ActivityPubPort>,
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
instance: InstanceIdentity,
}
impl CompositeSocialAdapter {
pub fn new(
ap_service: Arc<dyn ActivityPubPort>,
local: Arc<dyn LocalSocial>,
ap_service: Arc<ActivityPubService>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
instance: InstanceIdentity,
) -> Self {
Self {
local,
ap_service,
user_repo,
follow_command,
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(),
})
}
instance,
}
}
}
@@ -81,23 +41,10 @@ fn ap_err(e: anyhow::Error) -> DomainError {
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
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 follower == target_id {
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(());
if let SocialIdentity::Local(_) = identity {
return self.local.follow_resolved(follower, &identity).await;
}
let handle = match target {
@@ -109,7 +56,7 @@ impl SocialCommand for CompositeSocialAdapter {
.find_by_id(uid)
.await?
.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(),
},
@@ -125,21 +72,11 @@ impl SocialCommand for CompositeSocialAdapter {
follower: &UserId,
target: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
match target {
SocialIdentity::Local(target_id) => {
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::Local(_) => self.local.unfollow(follower, target).await,
SocialIdentity::Remote { .. } => self
.ap_service
.unfollow(follower.value(), &actor_url)
.unfollow(follower.value(), &self.instance.actor_url_of(target))
.await
.map_err(ap_err),
}
@@ -150,21 +87,11 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
requester: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester);
match requester {
SocialIdentity::Local(requester_id) => {
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::Local(_) => self.local.accept_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.accept_follower(owner.value(), &actor_url)
.accept_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
}
@@ -175,21 +102,11 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
requester: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(requester);
match requester {
SocialIdentity::Local(requester_id) => {
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::Local(_) => self.local.reject_follow(owner, requester).await,
SocialIdentity::Remote { .. } => self
.ap_service
.reject_follower(owner.value(), &actor_url)
.reject_follower(owner.value(), &self.instance.actor_url_of(requester))
.await
.map_err(ap_err),
}
@@ -200,28 +117,18 @@ impl SocialCommand for CompositeSocialAdapter {
owner: &UserId,
follower: &SocialIdentity,
) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(follower);
match follower {
SocialIdentity::Local(follower_id) => {
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::Local(_) => self.local.remove_follower(owner, follower).await,
SocialIdentity::Remote { .. } => self
.ap_service
.remove_follower(owner.value(), &actor_url)
.remove_follower(owner.value(), &self.instance.actor_url_of(follower))
.await
.map_err(ap_err),
}
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
let actor_url = self.instance.actor_url_of(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
@@ -229,7 +136,7 @@ impl SocialCommand for CompositeSocialAdapter {
}
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
.unblock_actor(blocker.value(), &actor_url)
.await
@@ -238,33 +145,46 @@ impl SocialCommand for CompositeSocialAdapter {
}
#[async_trait]
impl SocialQuery for CompositeSocialAdapter {
impl FollowGraphQuery for CompositeSocialAdapter {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_following(user.value(), &self.base_url)
.await
self.local.get_following(user).await
}
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_followers(user.value(), &self.base_url)
.await
self.local.get_followers(user).await
}
async fn get_pending_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_pending_followers(user.value(), &self.base_url)
.await
self.local.get_pending_followers(user).await
}
async fn get_pending_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.local.get_pending_following(user).await
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_following(user.value()).await
self.local.count_following(user).await
}
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> {
let actors = self
.ap_service
@@ -274,7 +194,7 @@ impl SocialQuery for CompositeSocialAdapter {
Ok(actors
.into_iter()
.map(|a| {
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
let identity = self.instance.identify(&a.url);
SocialActor {
identity,
handle: a.handle,
@@ -284,15 +204,4 @@ impl SocialQuery for CompositeSocialAdapter {
})
.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
}
}

View File

@@ -1,28 +1,43 @@
use domain::value_objects::ReviewId;
use domain::value_objects::{InstanceIdentity, ReviewId, UserId};
use url::Url;
/// Builds the canonical actor URL: `{base_url}/users/{user_id}`
pub fn actor_url(base_url: &str, user_id: uuid::Uuid) -> Url {
Url::parse(&format!("{}/users/{}", base_url, user_id))
pub fn actor_url(instance: &InstanceIdentity, user_id: uuid::Uuid) -> Url {
Url::parse(&instance.actor_url_for(&UserId::from_uuid(user_id)))
.expect("base_url is always a valid URL prefix")
}
/// Builds the canonical review URL: `{base_url}/reviews/{review_id}`
pub fn review_url(base_url: &str, review_id: &ReviewId) -> Url {
Url::parse(&format!("{}/reviews/{}", base_url, review_id.value()))
.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 {
pub fn review_url(instance: &InstanceIdentity, review_id: &ReviewId) -> Url {
Url::parse(&format!(
"{}/users/{}/watchlist/{}",
base_url, user_id, movie_id
"{}/reviews/{}",
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")
}

View File

@@ -1,28 +1,36 @@
use std::sync::Arc;
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use domain::{
ports::UserRepository,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{ApProfileField, ApUser, ApUserRepository};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub base_url: String,
pub instance: InstanceIdentity,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
pub fn new(repo: Arc<dyn UserRepository>, instance: InstanceIdentity) -> Self {
Self { repo, instance }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u
.avatar_path()
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let banner_url = u
.banner_path()
.and_then(|p| Url::parse(&format!("{}/images/{}", self.base_url, p)).ok());
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
.and_then(|p| Url::parse(&self.instance.image_url_for(p)).ok());
let profile_url = Url::parse(&format!(
"{}/u/{}",
self.instance.base_url(),
u.username().value()
))
.ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
@@ -46,12 +54,8 @@ impl DomainUserRepoAdapter {
manually_approves_followers: true,
discoverable: true,
actor_type: Default::default(),
featured_url: Url::parse(&format!(
"{}/users/{}/featured",
self.base_url,
u.id().value()
))
.ok(),
featured_url: Url::parse(&format!("{}/featured", self.instance.actor_url_for(u.id())))
.ok(),
}
}
}

View File

@@ -5,7 +5,7 @@ use chrono::DateTime;
use domain::{
models::{RemoteWatchlistEntry, WatchlistWithMovie},
ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId,
value_objects::{InstanceIdentity, UserId},
};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
@@ -16,7 +16,7 @@ use crate::urls::{actor_url, watchlist_entry_url};
pub struct WatchlistObjectHandler {
pub remote_watchlist_repo: Arc<dyn RemoteWatchlistRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub base_url: String,
pub instance: InstanceIdentity,
}
#[async_trait]
@@ -34,15 +34,15 @@ impl ApContentReader for WatchlistObjectHandler {
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, user_id);
let actor = actor_url(&self.instance, user_id);
let follower_cc = format!("{}/followers", actor);
let mut results = Vec::new();
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 poster_url = movie
.poster_path()
.map(|p| format!("{}/images/{}", self.base_url, p.value()));
.map(|p| self.instance.image_url_for(p.value()));
let obj = watchlist_to_ap_object(WatchlistApInput {
ap_id: ap_id.clone(),
actor_url: actor.clone(),
@@ -53,7 +53,7 @@ impl ApContentReader for WatchlistObjectHandler {
.map(|id| id.value().to_string()),
poster_url,
added_at: published,
base_url: self.base_url.clone(),
base_url: self.instance.base_url().to_string(),
});
results.push(LocalObject {
ap_id,

View File

@@ -14,6 +14,7 @@ sqlx = { version = "0.8.6", features = [
activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
postgres-social = { workspace = true }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,17 +1,8 @@
mod activity;
mod actor;
pub mod ap_content;
mod blocklist;
mod federated_profile;
mod follow;
mod follow_repository;
pub mod remote_goals;
mod review;
mod social;
mod watchlist;
pub use ap_content::PostgresApContentQuery;
pub use remote_goals::PostgresRemoteGoalRepository;
use k_ap::{FollowerStatus, RemoteActor};
use sqlx::{PgPool, Row};
@@ -72,23 +63,23 @@ impl PostgresFederationRepository {
}
}
pub fn create_federated_profile_query(
pub fn wire(
pool: PgPool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(PostgresFederationRepository::new(pool))
}
pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
instance: domain::value_objects::InstanceIdentity,
) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone()));
let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new(
pool, instance,
));
activitypub::FederationRepos {
activity: std::sync::Arc::clone(&fed) as _,
follow: std::sync::Arc::clone(&fed) as _,
actor: std::sync::Arc::clone(&fed) as _,
blocklist: std::sync::Arc::clone(&fed) as _,
admin_query: std::sync::Arc::clone(&fed) as _,
review_store: std::sync::Arc::clone(&fed) as _,
remote_watchlist: std::sync::Arc::clone(&fed) as _,
follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
review_store: fed as _,
admin_query: std::sync::Arc::clone(&social) as _,
remote_watchlist: std::sync::Arc::clone(&social) as _,
follow_command: std::sync::Arc::clone(&social) as _,
follow_query: social as _,
}
}

View 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 }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl FederatedProfileQuery for PostgresFederationRepository {
impl FederatedProfileQuery for PostgresSocialRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,

View File

@@ -2,11 +2,11 @@ use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::PostgresFederationRepository;
use crate::PostgresSocialRepository;
use adapter_common::datetime_to_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())
}
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
match status {
"pending" => Some(FollowStatus::Pending),
"accepted" => Some(FollowStatus::Accepted),
"rejected" => Some(FollowStatus::Rejected),
_ => None,
}
}
#[async_trait]
impl domain::ports::FollowCommand for PostgresFederationRepository {
impl domain::ports::FollowCommand for PostgresSocialRepository {
async fn add_follow(
&self,
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 identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let identity = instance.identify(&actor_url);
let (handle, display_name, avatar_url) = match &identity {
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")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
.map(|p| instance.image_url_for(&p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.map(|u| instance.handle_for(u))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.try_get::<Option<String>, _>("remote_handle")
.ok()
.flatten()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
@@ -181,12 +191,8 @@ fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialA
}
#[async_trait]
impl domain::ports::FollowQuery for PostgresFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
impl domain::ports::FollowQuery for PostgresSocialRepository {
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -197,22 +203,18 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.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, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -223,21 +225,20 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(base_url)
.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, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
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
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(base_url)
.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, 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())
}
@@ -284,20 +310,45 @@ impl domain::ports::FollowQuery for PostgresFederationRepository {
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_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(target_actor_url)
.fetch_one(&self.pool)
.await
.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),
})
}
}

View 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))
}

View File

@@ -1,10 +1,10 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl FederationAdminQuery for PostgresFederationRepository {
impl FederationAdminQuery for PostgresSocialRepository {
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'",

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
use sqlx::Row;
use super::PostgresFederationRepository;
use super::PostgresSocialRepository;
#[async_trait]
impl RemoteWatchlistRepository for PostgresFederationRepository {
impl RemoteWatchlistRepository for PostgresSocialRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \

View File

@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
] }
adapter-common = { workspace = true }
domain = { workspace = true }
postgres-federation = { workspace = true }
postgres-social = { workspace = true }
anyhow = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -28,7 +28,7 @@ pub use import_session::PostgresImportSessionRepository;
pub use movie::PostgresMovieRepository;
pub use movie_dedup::PostgresMovieDeduplicator;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use postgres_federation::PostgresApContentQuery;
pub use postgres_social::PostgresApContentQuery;
pub use profile::PostgresMovieProfileRepository;
pub use profile_fields::PostgresProfileFieldsRepository;
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 _,
user_settings: std::sync::Arc::clone(&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(),
)) as _,
deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _,

View File

@@ -192,7 +192,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
billing_order: r.try_get::<i32, _>("billing_order").unwrap_or(0) as u32,
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
@@ -210,31 +213,40 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
runtime_minutes: row
.try_get::<Option<i32>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
vote_count: row
.try_get::<Option<i32>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
original_language: row
.try_get::<Option<String>, _>("original_language")
.ok()
.flatten(),
collection_name: row
.try_get::<Option<String>, _>("collection_name")
.ok()
.flatten(),
genres,
keywords,
cast,

View File

@@ -8,6 +8,7 @@ sqlx = { workspace = true }
activitypub = { workspace = true }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
sqlite-social = { workspace = true }
domain = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -1,18 +1,8 @@
mod activity;
mod actor;
mod blocklist;
mod federated_profile;
mod follow;
mod follow_repository;
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 sqlx::SqlitePool;
@@ -84,24 +74,22 @@ impl SqliteFederationRepository {
}
}
pub fn create_federated_profile_query(
pub fn wire(
pool: SqlitePool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(SqliteFederationRepository::new(pool))
}
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
instance: domain::value_objects::InstanceIdentity,
) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool.clone()));
let social = std::sync::Arc::new(sqlite_social::SqliteSocialRepository::new(pool, instance));
activitypub::FederationRepos {
activity: std::sync::Arc::clone(&fed) as _,
follow: std::sync::Arc::clone(&fed) as _,
actor: std::sync::Arc::clone(&fed) as _,
blocklist: std::sync::Arc::clone(&fed) as _,
admin_query: std::sync::Arc::clone(&fed) as _,
review_store: std::sync::Arc::clone(&fed) as _,
remote_watchlist: std::sync::Arc::clone(&fed) as _,
follow_command: std::sync::Arc::clone(&fed) as _,
follow_query: fed as _,
review_store: fed as _,
admin_query: std::sync::Arc::clone(&social) as _,
remote_watchlist: std::sync::Arc::clone(&social) as _,
follow_command: std::sync::Arc::clone(&social) as _,
follow_query: social as _,
}
}

View File

@@ -1,6 +1,5 @@
use super::*;
use chrono::Utc;
use domain::ports::FederationAdminQuery;
use k_ap::AnnounceRepository;
use sqlx::SqlitePool;
@@ -48,65 +47,3 @@ async fn duplicate_announce_is_ignored() {
.unwrap();
assert_eq!(repo.count_announces("https://local/r/1").await.unwrap(), 1);
}
async fn setup_db(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_remote_actors (
url TEXT PRIMARY KEY,
handle TEXT NOT NULL,
inbox_url TEXT NOT NULL,
shared_inbox_url TEXT,
display_name TEXT,
avatar_url TEXT,
fetched_at TEXT NOT NULL
)",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"CREATE TABLE IF NOT EXISTS ap_following (
local_user_id TEXT NOT NULL,
remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT NOT NULL,
status TEXT NOT NULL,
PRIMARY KEY (local_user_id, remote_actor_url)
)",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn test_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");
}

View 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 }

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl FederatedProfileQuery for SqliteFederationRepository {
impl FederatedProfileQuery for SqliteSocialRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,

View File

@@ -2,11 +2,11 @@ use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::SqliteFederationRepository;
use crate::SqliteSocialRepository;
use adapter_common::datetime_to_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())
}
fn follow_status_from_str(status: &str) -> Option<FollowStatus> {
match status {
"pending" => Some(FollowStatus::Pending),
"accepted" => Some(FollowStatus::Accepted),
"rejected" => Some(FollowStatus::Rejected),
_ => None,
}
}
#[async_trait]
impl domain::ports::FollowCommand for SqliteFederationRepository {
impl domain::ports::FollowCommand for SqliteSocialRepository {
async fn add_follow(
&self,
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 identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let identity = instance.identify(&actor_url);
let (handle, display_name, avatar_url) = match &identity {
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")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
.map(|p| instance.image_url_for(&p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.map(|u| instance.handle_for(u))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.try_get::<Option<String>, _>("remote_handle")
.ok()
.flatten()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
@@ -181,12 +194,8 @@ fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> Socia
}
#[async_trait]
impl domain::ports::FollowQuery for SqliteFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
impl domain::ports::FollowQuery for SqliteSocialRepository {
async fn get_following(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -197,22 +206,18 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.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, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
async fn get_followers(&self, user_id: uuid::Uuid) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
@@ -223,21 +228,20 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(base_url)
.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, base_url))
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
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
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
)
.bind(base_url)
.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, 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())
}
@@ -284,20 +313,45 @@ impl domain::ports::FollowQuery for SqliteFederationRepository {
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_id.to_string();
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_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(target_actor_url)
.fetch_one(&self.pool)
.await
.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),
})
}
}

View 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;

View File

@@ -1,10 +1,10 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl FederationAdminQuery for SqliteFederationRepository {
impl FederationAdminQuery for SqliteSocialRepository {
async fn list_all_followed_remote_actors(&self) -> Result<Vec<RemoteActorInfo>, DomainError> {
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT DISTINCT ar.url, ar.handle, ar.display_name

View 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");
}

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository};
use sqlx::Row;
use super::SqliteFederationRepository;
use super::SqliteSocialRepository;
#[async_trait]
impl RemoteWatchlistRepository for SqliteFederationRepository {
impl RemoteWatchlistRepository for SqliteSocialRepository {
async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> {
sqlx::query(
"INSERT INTO ap_remote_watchlist_entries \

View File

@@ -13,7 +13,7 @@ sqlx = { version = "0.8.6", features = [
adapter-common = { workspace = true }
domain = { workspace = true }
sqlite-federation = { workspace = true }
sqlite-social = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
anyhow = { workspace = true }

View File

@@ -32,7 +32,7 @@ pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use refresh_sessions::SqliteRefreshSessionAdapter;
pub use review::SqliteReviewRepository;
pub use sqlite_federation::SqliteApContentQuery;
pub use sqlite_social::SqliteApContentQuery;
pub use stats::SqliteStatsRepository;
pub use users::SqliteUserRepository;
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 _,
user_settings: std::sync::Arc::clone(&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(),
)) as _,
deduplicator: std::sync::Arc::new(SqliteMovieDeduplicator::new(pool)) as _,

View File

@@ -208,7 +208,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
character: r.try_get("character").unwrap_or_default(),
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();
@@ -226,31 +229,40 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
name: r.try_get("name").unwrap_or_default(),
job: r.try_get("job").unwrap_or_default(),
department: r.try_get("department").unwrap_or_default(),
profile_path: r.try_get("profile_path").ok(),
profile_path: r
.try_get::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get("imdb_id").ok(),
overview: row.try_get("overview").ok(),
tagline: row.try_get("tagline").ok(),
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
runtime_minutes: row
.try_get::<Option<i64>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get("budget_usd").ok(),
revenue_usd: row.try_get("revenue_usd").ok(),
vote_average: row.try_get("vote_average").ok(),
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
vote_count: row
.try_get::<Option<i64>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row.try_get("original_language").ok(),
collection_name: row.try_get("collection_name").ok(),
original_language: row
.try_get::<Option<String>, _>("original_language")
.ok()
.flatten(),
collection_name: row
.try_get::<Option<String>, _>("collection_name")
.ok()
.flatten(),
genres,
keywords,
cast,
@@ -286,3 +298,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.collect())
}
}
#[cfg(test)]
#[path = "tests/profile.rs"]
mod tests;

View 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)"
);
}

View File

@@ -296,6 +296,7 @@ pub struct FollowingTemplate {
pub ctx: HtmlPageContext,
pub user_id: uuid::Uuid,
pub actors: Vec<RemoteActorData>,
pub pending_actors: Vec<RemoteActorData>,
pub error: Option<String>,
}

View File

@@ -34,7 +34,7 @@
<a href="/">Feed</a>
<a href="/users">Users</a>
{% 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="/import">Import</a>
<a href="/watch-queue">Queue</a>

View File

@@ -10,6 +10,28 @@
<input type="text" name="handle" placeholder="@user@instance.tld" required>
<button type="submit">Follow</button>
</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() %}
<p>Not following anyone yet. Follow remote users from your <a href="/users/{{ user_id }}">profile page</a>.</p>
{% else %}

View File

@@ -8,3 +8,6 @@ serde = { workspace = true }
uuid = { workspace = true }
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid"] }
domain = { path = "../domain" }
[dev-dependencies]
serde_json = { workspace = true }

View File

@@ -10,6 +10,7 @@ pub struct HtmlPageContext {
pub canonical_url: String,
pub csrf_token: String,
pub page_rss_url: Option<String>,
pub pending_follow_count: usize,
}
impl HtmlPageContext {

View File

@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct FollowRequest {
@@ -15,6 +16,9 @@ pub struct RemoteActorDto {
pub handle: String,
pub display_name: Option<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)]
@@ -42,3 +46,62 @@ pub struct BlockedActorResponse {
pub display_name: 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\""
);
}
}

View File

@@ -31,3 +31,7 @@ pub struct RegisterAndLoginDeps {
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub config: AppConfig,
}
pub struct LogoutDeps {
pub refresh_session: Arc<dyn RefreshSessionRepository>,
}

View File

@@ -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(
refresh_session: Arc<dyn RefreshSessionRepository>,
refresh_token: &str,
) -> Result<(), DomainError> {
refresh_session.revoke(refresh_token).await
pub async fn execute(deps: &LogoutDeps, refresh_token: &str) -> Result<(), DomainError> {
deps.refresh_session.revoke(refresh_token).await
}
#[cfg(test)]

View File

@@ -6,7 +6,7 @@ use domain::testing::InMemoryUserRepository;
use crate::{
auth::{
commands::RegisterCommand,
deps::{LoginDeps, RefreshDeps, RegisterDeps},
deps::{LoginDeps, LogoutDeps, RefreshDeps, RegisterDeps},
login, logout,
queries::LoginCommand,
refresh, register,
@@ -53,7 +53,10 @@ async fn logout_revokes_refresh_token() {
.await
.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
.unwrap();
@@ -69,6 +72,9 @@ async fn logout_revokes_refresh_token() {
#[tokio::test]
async fn logout_with_unknown_token_succeeds() {
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());
}

View 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,
}

View File

@@ -1,8 +1,8 @@
use std::sync::Arc;
use domain::ports::{
DiaryQuery, EventPublisher, MovieCommand, MovieProfileRepository, MovieQuery, ReviewRepository,
SocialQuery,
DiaryExporter, DiaryQuery, EventPublisher, FollowGraphQuery, MovieCommand,
MovieProfileRepository, MovieQuery, ReviewRepository, UserRepository,
};
use crate::config::AppConfig;
@@ -27,6 +27,24 @@ pub struct GetMovieSocialPageDeps {
pub struct GetActivityFeedDeps {
pub diary: Arc<dyn DiaryQuery>,
pub social_query: Arc<dyn SocialQuery>,
pub social_query: Arc<dyn FollowGraphQuery>,
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>,
}

View File

@@ -1,21 +1,16 @@
use std::sync::Arc;
use bytes::Bytes;
use domain::{
errors::DomainError,
ports::{DiaryExporter, DiaryQuery},
value_objects::UserId,
};
use domain::{errors::DomainError, value_objects::UserId};
use futures::stream::BoxStream;
use crate::diary::deps::ExportDiaryDeps;
use crate::diary::queries::ExportQuery;
pub fn execute(
diary: &Arc<dyn DiaryQuery>,
diary_exporter: &Arc<dyn DiaryExporter>,
deps: &ExportDiaryDeps,
query: ExportQuery,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
let user_id = UserId::from_uuid(query.user_id);
let entry_stream = diary.stream_user_history(user_id);
diary_exporter.stream_entries(entry_stream, query.format)
let entry_stream = deps.diary.stream_user_history(user_id);
deps.diary_exporter
.stream_entries(entry_stream, query.format)
}

View File

@@ -1,19 +1,17 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, ReviewSortBy,
collections::{PageParams, Paginated},
},
ports::DiaryQuery,
value_objects::{MovieId, UserId},
};
use crate::diary::deps::GetDiaryDeps;
use crate::diary::queries::GetDiaryQuery;
pub async fn execute(
diary: &Arc<dyn DiaryQuery>,
deps: &GetDiaryDeps,
query: GetDiaryQuery,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?;
@@ -29,7 +27,7 @@ pub async fn execute(
include_remote: user_id.is_some(),
};
diary.query_diary(&filter).await
deps.diary.query_diary(&filter).await
}
#[cfg(test)]

View File

@@ -1,22 +1,20 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
models::ReviewHistory,
ports::DiaryQuery,
services::review_history::{ReviewHistoryAnalyzer, Trend},
value_objects::MovieId,
};
use crate::diary::deps::GetReviewHistoryDeps;
use crate::diary::queries::GetReviewHistoryQuery;
pub async fn execute(
diary: &Arc<dyn DiaryQuery>,
deps: &GetReviewHistoryDeps,
query: GetReviewHistoryQuery,
) -> Result<(ReviewHistory, Trend), DomainError> {
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)?;

View 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;

View File

@@ -7,6 +7,7 @@ pub mod get_activity_feed;
pub mod get_diary;
pub mod get_movie_social_page;
pub mod get_review_history;
pub mod get_user_feed;
pub mod log_review;
pub mod movie_resolver;
pub mod queries;

View File

@@ -3,7 +3,7 @@ use std::sync::Arc;
use async_trait::async_trait;
use domain::errors::DomainError;
use domain::testing::InMemorySocialRepository;
use domain::value_objects::{SocialActor, SocialIdentity, UserId};
use domain::value_objects::{FollowRelation, SocialActor, SocialIdentity, UserId};
use crate::{
config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
@@ -66,7 +66,7 @@ async fn returns_feed_with_following_filter() {
struct FakeSocialWithFollowing(Vec<SocialActor>);
#[async_trait]
impl domain::ports::SocialQuery for FakeSocialWithFollowing {
impl domain::ports::FollowGraphQuery for FakeSocialWithFollowing {
async fn get_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
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> {
Ok(vec![])
}
async fn get_pending_following(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
}
async fn count_following(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn count_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn get_blocked(&self, _: &UserId) -> Result<Vec<SocialActor>, DomainError> {
Ok(vec![])
async fn count_pending_followers(&self, _: &UserId) -> Result<usize, DomainError> {
Ok(0)
}
async fn is_following(&self, _: &UserId, _: &SocialIdentity) -> Result<bool, DomainError> {
Ok(false)
async fn get_relation(
&self,
_: &UserId,
_: &SocialIdentity,
) -> Result<FollowRelation, DomainError> {
Ok(FollowRelation::default())
}
}

View File

@@ -1,14 +1,15 @@
use domain::testing::FakeDiaryQuery;
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]
async fn returns_empty_page() {
let diary = FakeDiaryQuery::new() as Arc<dyn domain::ports::DiaryQuery>;
let deps = GetDiaryDeps { diary };
let result = get_diary::execute(
&diary,
&deps,
GetDiaryQuery {
limit: None,
offset: None,

View File

@@ -7,7 +7,10 @@ use domain::{
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]
async fn returns_empty_history() {
@@ -23,8 +26,9 @@ async fn returns_empty_history() {
let diary = domain::testing::FakeDiaryQuery::new();
diary.seed_history(movie, vec![]);
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
.unwrap();

View 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(_)));
}

View 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;

View File

@@ -31,6 +31,12 @@ pub struct ApplyImportProfileCommand {
pub profile_id: Uuid,
}
pub struct ApplyProfileAndMapCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub profile_id: Uuid,
}
pub struct DeleteImportProfileCommand {
pub user_id: Uuid,
pub profile_id: Uuid,

View File

@@ -1,24 +1,22 @@
use std::sync::Arc;
use crate::import::commands::DeleteImportProfileCommand;
use crate::import::deps::DeleteImportProfileDeps;
use domain::{
errors::DomainError,
ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId},
};
pub async fn execute(
import_profile: Arc<dyn ImportProfileRepository>,
deps: &DeleteImportProfileDeps,
cmd: DeleteImportProfileCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
import_profile
deps.import_profile
.get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
import_profile.delete(&profile_id).await
deps.import_profile.delete(&profile_id).await
}
#[cfg(test)]

View File

@@ -29,3 +29,35 @@ pub struct SaveProfileDeps {
pub import_session: Arc<dyn ImportSessionRepository>,
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>,
}

View 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;

View 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;

View 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;

View File

@@ -1,15 +1,11 @@
use std::sync::Arc;
use domain::{
errors::DomainError, models::ImportProfile, ports::ImportProfileRepository,
value_objects::UserId,
};
use crate::import::deps::ListImportProfilesDeps;
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
pub async fn execute(
import_profile: Arc<dyn ImportProfileRepository>,
deps: &ListImportProfilesDeps,
user_id: &UserId,
) -> Result<Vec<ImportProfile>, DomainError> {
import_profile.list_for_user(user_id).await
deps.import_profile.list_for_user(user_id).await
}
#[cfg(test)]

View File

@@ -1,10 +1,14 @@
pub mod apply_mapping;
pub mod apply_profile;
pub mod apply_profile_and_map;
pub mod cleanup;
pub mod commands;
pub mod create_session;
pub mod delete_profile;
pub mod deps;
pub mod execute;
pub mod get_mapping_stage;
pub mod get_preview_stage;
pub mod get_session_state;
pub mod list_profiles;
pub mod save_profile;

View 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());
}

View File

@@ -3,14 +3,19 @@ use std::sync::Arc;
use domain::testing::InMemoryImportProfileRepository;
use uuid::Uuid;
use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
use crate::import::{
commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps,
};
#[tokio::test]
async fn fails_when_profile_not_found() {
let profiles = InMemoryImportProfileRepository::new();
let deps = DeleteImportProfileDeps {
import_profile: Arc::clone(&profiles) as _,
};
let result = delete_profile::execute(
Arc::clone(&profiles) as _,
&deps,
DeleteImportProfileCommand {
user_id: Uuid::new_v4(),
profile_id: Uuid::new_v4(),

View 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);
}

View 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());
}

View 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);
}

View File

@@ -4,16 +4,17 @@ use domain::testing::InMemoryImportProfileRepository;
use domain::value_objects::UserId;
use uuid::Uuid;
use crate::import::list_profiles;
use crate::import::{deps::ListImportProfilesDeps, list_profiles};
#[tokio::test]
async fn returns_empty_when_no_profiles() {
let profiles = InMemoryImportProfileRepository::new();
let deps = ListImportProfilesDeps {
import_profile: Arc::clone(&profiles) as _,
};
let user_id = UserId::from_uuid(Uuid::new_v4());
let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id)
.await
.unwrap();
let result = list_profiles::execute(&deps, &user_id).await.unwrap();
assert!(result.is_empty());
}

View File

@@ -1,22 +1,16 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
models::WatchEventStatus,
ports::{WatchEventCommand, WatchEventQuery},
value_objects::{UserId, WatchEventId},
};
use crate::{
diary::commands::{LogReviewCommand, MovieInput},
integrations::commands::ConfirmWatchEventsCommand,
ports::ReviewLogger,
integrations::{commands::ConfirmWatchEventsCommand, deps::ConfirmWatchEventsDeps},
};
pub async fn execute(
watch_event_command: Arc<dyn WatchEventCommand>,
watch_event_query: Arc<dyn WatchEventQuery>,
review_logger: Arc<dyn ReviewLogger>,
deps: &ConfirmWatchEventsDeps,
cmd: ConfirmWatchEventsCommand,
) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
@@ -24,7 +18,8 @@ pub async fn execute(
for c in cmd.confirmations {
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)
.await?
.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),
};
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)
.await?;

View File

@@ -2,9 +2,38 @@ use std::sync::Arc;
use domain::ports::{EventPublisher, WatchEventCommand, WatchEventQuery, WebhookTokenRepository};
use crate::ports::ReviewLogger;
pub struct IngestWatchEventDeps {
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub watch_event_command: Arc<dyn WatchEventCommand>,
pub watch_event_query: Arc<dyn WatchEventQuery>,
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>,
}

View File

@@ -1,17 +1,13 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
models::WatchEventStatus,
ports::{WatchEventCommand, WatchEventQuery},
value_objects::{UserId, WatchEventId},
};
use crate::integrations::commands::DismissWatchEventsCommand;
use crate::integrations::{commands::DismissWatchEventsCommand, deps::DismissWatchEventsDeps};
pub async fn execute(
watch_event_command: Arc<dyn WatchEventCommand>,
watch_event_query: Arc<dyn WatchEventQuery>,
deps: &DismissWatchEventsDeps,
cmd: DismissWatchEventsCommand,
) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
@@ -25,7 +21,7 @@ pub async fn execute(
.map(|id| WatchEventId::from_uuid(*id))
.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() {
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)
.await?;

View File

@@ -1,11 +1,7 @@
use std::sync::Arc;
use domain::{
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
};
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId};
use sha2::{Digest, Sha256};
use crate::integrations::commands::GenerateWebhookTokenCommand;
use crate::integrations::{commands::GenerateWebhookTokenCommand, deps::GenerateWebhookTokenDeps};
pub struct GeneratedWebhookToken {
pub token_plaintext: String,
@@ -13,7 +9,7 @@ pub struct GeneratedWebhookToken {
}
pub async fn execute(
webhook_token: Arc<dyn WebhookTokenRepository>,
deps: &GenerateWebhookTokenDeps,
cmd: GenerateWebhookTokenCommand,
) -> Result<GeneratedWebhookToken, DomainError> {
let plaintext = generate_random_token();
@@ -22,7 +18,7 @@ pub async fn execute(
let user_id = UserId::from_uuid(cmd.user_id);
let token = WebhookToken::new(user_id, hash, cmd.provider, cmd.label);
webhook_token.save(&token).await?;
deps.webhook_token.save(&token).await?;
Ok(GeneratedWebhookToken {
token_plaintext: plaintext,

View File

@@ -1,17 +1,13 @@
use std::sync::Arc;
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId};
use domain::{
errors::DomainError, models::WatchEvent, ports::WatchEventQuery, value_objects::UserId,
};
use crate::integrations::queries::GetWatchQueueQuery;
use crate::integrations::{deps::GetWatchQueueDeps, queries::GetWatchQueueQuery};
pub async fn execute(
watch_event_query: Arc<dyn WatchEventQuery>,
deps: &GetWatchQueueDeps,
query: GetWatchQueueQuery,
) -> Result<Vec<WatchEvent>, DomainError> {
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)]

View File

@@ -1,17 +1,13 @@
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 crate::integrations::queries::GetWebhookTokensQuery;
use crate::integrations::{deps::GetWebhookTokensDeps, queries::GetWebhookTokensQuery};
pub async fn execute(
webhook_token: Arc<dyn WebhookTokenRepository>,
deps: &GetWebhookTokensDeps,
query: GetWebhookTokensQuery,
) -> Result<Vec<WebhookToken>, DomainError> {
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)]

View File

@@ -1,20 +1,17 @@
use std::sync::Arc;
use domain::{
errors::DomainError,
ports::WebhookTokenRepository,
value_objects::{UserId, WebhookTokenId},
};
use crate::integrations::commands::RevokeWebhookTokenCommand;
use crate::integrations::{commands::RevokeWebhookTokenCommand, deps::RevokeWebhookTokenDeps};
pub async fn execute(
webhook_token: Arc<dyn WebhookTokenRepository>,
deps: &RevokeWebhookTokenDeps,
cmd: RevokeWebhookTokenCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_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)]

View File

@@ -8,12 +8,24 @@ use uuid::Uuid;
use crate::integrations::commands::{ConfirmWatchEventsCommand, WatchEventConfirmation};
use crate::integrations::confirm;
use crate::integrations::deps::ConfirmWatchEventsDeps;
use crate::test_helpers::NoopReviewLogger;
fn noop_logger() -> Arc<dyn crate::ports::ReviewLogger> {
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]
async fn confirms_watch_event_via_review_logger() {
let watch_events = InMemoryWatchEventRepository::new();
@@ -32,9 +44,7 @@ async fn confirms_watch_event_via_review_logger() {
watch_events.save(&event).await.unwrap();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: uid,
confirmations: vec![WatchEventConfirmation {
@@ -55,9 +65,7 @@ async fn empty_confirmations_returns_zero() {
let watch_events = InMemoryWatchEventRepository::new();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: Uuid::new_v4(),
confirmations: vec![],
@@ -87,9 +95,7 @@ async fn confirms_event_with_external_metadata_id_and_no_movie_id() {
watch_events.save(&event).await.unwrap();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: uid,
confirmations: vec![WatchEventConfirmation {
@@ -124,9 +130,7 @@ async fn rejects_other_users_event() {
watch_events.save(&event).await.unwrap();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: intruder,
confirmations: vec![WatchEventConfirmation {
@@ -146,9 +150,7 @@ async fn fails_when_event_not_found() {
let watch_events = InMemoryWatchEventRepository::new();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: Uuid::new_v4(),
confirmations: vec![WatchEventConfirmation {
@@ -208,9 +210,7 @@ async fn confirms_event_with_movie_id() {
));
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
review_logger,
&deps(&watch_events, review_logger),
ConfirmWatchEventsCommand {
user_id: uid,
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();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: uid,
confirmations: vec![WatchEventConfirmation {
@@ -293,9 +291,7 @@ async fn confirms_multiple_events() {
watch_events.save(&event2).await.unwrap();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: uid,
confirmations: vec![
@@ -336,9 +332,7 @@ async fn confirms_event_without_year() {
watch_events.save(&event).await.unwrap();
let result = confirm::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
noop_logger(),
&deps(&watch_events, noop_logger()),
ConfirmWatchEventsCommand {
user_id: uid,
confirmations: vec![WatchEventConfirmation {

View File

@@ -6,15 +6,22 @@ use domain::testing::InMemoryWatchEventRepository;
use domain::value_objects::UserId;
use uuid::Uuid;
use crate::integrations::deps::DismissWatchEventsDeps;
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]
async fn dismisses_empty_list_returns_zero() {
let events = InMemoryWatchEventRepository::new();
let result = dismiss::execute(
Arc::clone(&events) as _,
Arc::clone(&events) as _,
&deps(&events),
DismissWatchEventsCommand {
user_id: Uuid::new_v4(),
event_ids: vec![],
@@ -31,8 +38,7 @@ async fn fails_when_event_not_found() {
let events = InMemoryWatchEventRepository::new();
let result = dismiss::execute(
Arc::clone(&events) as _,
Arc::clone(&events) as _,
&deps(&events),
DismissWatchEventsCommand {
user_id: Uuid::new_v4(),
event_ids: vec![Uuid::new_v4()],
@@ -73,8 +79,7 @@ async fn dismisses_existing_events() {
watch_events.save(&e2).await.unwrap();
let result = dismiss::execute(
Arc::clone(&watch_events) as _,
Arc::clone(&watch_events) as _,
&deps(&watch_events),
DismissWatchEventsCommand {
user_id: uid,
event_ids: vec![id1, id2],

View File

@@ -5,6 +5,7 @@ use domain::ports::WebhookTokenRepository;
use domain::testing::InMemoryWebhookTokenRepository;
use uuid::Uuid;
use crate::integrations::deps::GenerateWebhookTokenDeps;
use crate::integrations::{commands::GenerateWebhookTokenCommand, generate_token};
#[tokio::test]
@@ -13,7 +14,9 @@ async fn generates_token_and_saves() {
let user_id = Uuid::new_v4();
let result = generate_token::execute(
Arc::clone(&tokens),
&GenerateWebhookTokenDeps {
webhook_token: Arc::clone(&tokens),
},
GenerateWebhookTokenCommand {
user_id,
provider: WatchEventSource::Jellyfin,

View File

@@ -7,14 +7,21 @@ use domain::testing::InMemoryWatchEventRepository;
use domain::value_objects::UserId;
use uuid::Uuid;
use crate::integrations::deps::GetWatchQueueDeps;
use crate::integrations::{get_queue, queries::GetWatchQueueQuery};
fn deps(events: &Arc<InMemoryWatchEventRepository>) -> GetWatchQueueDeps {
GetWatchQueueDeps {
watch_event_query: Arc::clone(events) as _,
}
}
#[tokio::test]
async fn returns_empty_when_no_events() {
let events = InMemoryWatchEventRepository::new();
let result = get_queue::execute(
Arc::clone(&events) as _,
&deps(&events),
GetWatchQueueQuery {
user_id: Uuid::new_v4(),
},
@@ -41,7 +48,7 @@ async fn returns_pending_events() {
);
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
.unwrap();

View File

@@ -5,17 +5,30 @@ use domain::ports::WebhookTokenRepository;
use domain::testing::InMemoryWebhookTokenRepository;
use uuid::Uuid;
use crate::integrations::deps::{GenerateWebhookTokenDeps, GetWebhookTokensDeps};
use crate::integrations::{
commands::GenerateWebhookTokenCommand, generate_token, get_tokens,
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]
async fn returns_empty_when_no_tokens() {
let tokens: Arc<dyn WebhookTokenRepository> = InMemoryWebhookTokenRepository::new();
let result = get_tokens::execute(
Arc::clone(&tokens),
&get_deps(&tokens),
GetWebhookTokensQuery {
user_id: Uuid::new_v4(),
},
@@ -33,7 +46,7 @@ async fn returns_tokens_after_generate() {
let user_id = Uuid::new_v4();
generate_token::execute(
Arc::clone(&tokens),
&generate_deps(&tokens),
GenerateWebhookTokenCommand {
user_id,
provider: WatchEventSource::Jellyfin,
@@ -44,7 +57,7 @@ async fn returns_tokens_after_generate() {
.unwrap();
generate_token::execute(
Arc::clone(&tokens),
&generate_deps(&tokens),
GenerateWebhookTokenCommand {
user_id,
provider: WatchEventSource::Plex,
@@ -54,7 +67,7 @@ async fn returns_tokens_after_generate() {
.await
.unwrap();
let result = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
let result = get_tokens::execute(&get_deps(&tokens), GetWebhookTokensQuery { user_id })
.await
.unwrap();

View File

@@ -8,7 +8,7 @@ use domain::testing::{
use uuid::Uuid;
use crate::integrations::commands::{GenerateWebhookTokenCommand, IngestWatchEventCommand};
use crate::integrations::deps::IngestWatchEventDeps;
use crate::integrations::deps::{GenerateWebhookTokenDeps, IngestWatchEventDeps};
use crate::integrations::{generate_token, ingest};
struct FakeParser;
@@ -35,7 +35,9 @@ async fn ingests_watch_event() {
let user_id = Uuid::new_v4();
let generated = generate_token::execute(
Arc::clone(&tokens),
&GenerateWebhookTokenDeps {
webhook_token: Arc::clone(&tokens),
},
GenerateWebhookTokenCommand {
user_id,
provider: WatchEventSource::Jellyfin,

View File

@@ -5,6 +5,9 @@ use domain::ports::WebhookTokenRepository;
use domain::testing::InMemoryWebhookTokenRepository;
use uuid::Uuid;
use crate::integrations::deps::{
GenerateWebhookTokenDeps, GetWebhookTokensDeps, RevokeWebhookTokenDeps,
};
use crate::integrations::{
commands::{GenerateWebhookTokenCommand, RevokeWebhookTokenCommand},
generate_token, get_tokens,
@@ -19,7 +22,9 @@ async fn revokes_existing_token() {
let user_id = Uuid::new_v4();
let generated = generate_token::execute(
Arc::clone(&tokens),
&GenerateWebhookTokenDeps {
webhook_token: Arc::clone(&tokens),
},
GenerateWebhookTokenCommand {
user_id,
provider: WatchEventSource::Jellyfin,
@@ -32,15 +37,22 @@ async fn revokes_existing_token() {
let token_id = generated.token.id().value();
revoke_token::execute(
Arc::clone(&tokens),
&RevokeWebhookTokenDeps {
webhook_token: Arc::clone(&tokens),
},
RevokeWebhookTokenCommand { user_id, token_id },
)
.await
.unwrap();
let remaining = get_tokens::execute(Arc::clone(&tokens), GetWebhookTokensQuery { user_id })
.await
.unwrap();
let remaining = get_tokens::execute(
&GetWebhookTokensDeps {
webhook_token: Arc::clone(&tokens),
},
GetWebhookTokensQuery { user_id },
)
.await
.unwrap();
assert!(remaining.is_empty());
}

View File

@@ -58,13 +58,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
start_date: start,
end_date: end,
};
if let Err(e) = crate::wrapup::generate::execute(
self.wrapup_repo.clone(),
self.event_publisher.clone(),
cmd,
)
.await
{
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
wrapup_repo: self.wrapup_repo.clone(),
event_publisher: self.event_publisher.clone(),
};
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
tracing::warn!(
"auto-generate wrapup for user {} failed: {e}",
user.user_id.value()
@@ -81,13 +79,11 @@ impl PeriodicJob for WrapUpAutoGenerateJob {
start_date: start,
end_date: end,
};
if let Err(e) = crate::wrapup::generate::execute(
self.wrapup_repo.clone(),
self.event_publisher.clone(),
cmd,
)
.await
{
let deps = crate::wrapup::deps::GenerateWrapUpDeps {
wrapup_repo: self.wrapup_repo.clone(),
event_publisher: self.event_publisher.clone(),
};
if let Err(e) = crate::wrapup::generate::execute(&deps, cmd).await {
tracing::warn!("auto-generate global wrapup failed: {e}");
}
}

View File

@@ -1,6 +1,8 @@
pub mod config;
pub mod deps;
pub mod jobs;
pub mod ports;
pub mod services;
pub mod worker;
pub mod auth;
@@ -19,6 +21,13 @@ pub mod wrapup;
#[cfg(test)]
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::SearchCleanupHandler;
pub use movies::SearchReindexHandler;
pub use services::Services;

Some files were not shown because too many files have changed in this diff Show More