Compare commits

..

60 Commits

Author SHA1 Message Date
22b1dd3f56 fix: portal wrapup share card to escape transform stacking context
All checks were successful
CI / Check / Test (push) Successful in 1h5m29s
2026-07-27 14:07:36 +02:00
813778dc7e fix: users table has avatar_path not avatar_url, resolve to full URL
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 13:08:45 +02:00
063bab910b ADR-0003: local follows bypass AP, unified FollowCommand/FollowQuery ports, FederationRepos struct, base_url normalization
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 12:56:30 +02:00
839cababe4 bump k-ap 0.5.0, decompose repo traits, LocalObject, ActivityStreamsType enum, split follow modules
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 11:46:24 +02:00
fdf286ca94 fix: remove split-debuginfo option from dev profile in Cargo.toml
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-27 11:19:54 +02:00
e30e785936 bump k-ap to 0.4.6, add nodeinfo metadata 2026-07-16 09:56:54 +02:00
10be024bdf fix: update k-ap dependency version to 0.4.4
All checks were successful
CI / Check / Test (push) Successful in 1h5m51s
2026-07-11 10:45:43 +02:00
1e33c6d184 fix: update k-ap dependency version to 0.4.4
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-11 10:40:53 +02:00
96168cfc7b fix: update k-ap dependency version to 0.4.3
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-11 10:21:28 +02:00
95b839355f fix: portal search overlay to escape transform stacking context, auto-fill IMDB from ttXXXX input
All checks were successful
CI / Check / Test (push) Successful in 1h12m50s
2026-07-11 01:44:08 +02:00
85285b2a52 github ci fix
All checks were successful
CI / Check / Test (push) Successful in 1h9m41s
2026-07-10 23:29:17 +02:00
c3d8bcad29 fix: collapse nested if for clippy
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 23:20:49 +02:00
8aa64ab174 fix: restrict swipe-tabs drag to touch-only, prevent ghost swipe after drawer close
Some checks failed
CI / Check / Test (push) Failing after 8m25s
2026-07-10 22:52:50 +02:00
d84c54bb30 feat: page transitions, poster hover, star bounce, reveal scale-up 2026-07-10 22:51:09 +02:00
41bed3583d feat: calendar diary view on profile (#15)
Some checks failed
CI / Check / Test (push) Failing after 8m29s
2026-07-10 22:32:47 +02:00
0a8b52514d feat: calendar diary view on profile (#15) 2026-07-10 22:22:13 +02:00
a0c0ba1c5d rename profile Trends tab to Stats
Some checks failed
CI / Check / Test (push) Failing after 8m35s
2026-07-10 22:01:56 +02:00
94cab1ea7c fix: lock body scroll + remove scrollbar on wrapup share card
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 21:58:38 +02:00
3dde0d13db docs: sync README + architecture diagram with current codebase 2026-07-10 21:56:16 +02:00
1c81d7768c fix: duplicate count in wrapup watch medium display 2026-07-10 21:53:21 +02:00
07df6fe207 review detail sheet on diary/profile/community, wrapup period presets 2026-07-10 21:51:42 +02:00
1a7448fd3d fix: use strict > for min-count filters in wrapup stats
Some checks failed
CI / Check / Test (push) Failing after 8m38s
2026-07-10 21:42:29 +02:00
587dcc04de feat: richer profile stats, wrapup min-count filter, watch medium distribution
Some checks failed
CI / Check / Test (push) Has been cancelled
- filter wrapup top directors/actors with count < 2
- add watch_medium to wrapup report + adapters
- profile trends: genre breakdown, rating histogram, watch medium dist
- fix unused StarDisplay import in movie detail
2026-07-10 21:38:08 +02:00
498f3b1818 edit sheet delete btn, fix medium icons, 44px touch targets, fix drag leak on drawer close
Some checks failed
CI / Check / Test (push) Failing after 8m44s
2026-07-10 17:46:56 +02:00
0ee2e04fe5 refactor(spa): collapse api/hooks telescope, merge review sheets, extract tab+list modules
- merge lib/api/* + hooks/use-* → features/* domain modules
- log-sheet + edit-review-sheet → review-sheet with mode discriminant
- index.tsx → feed-tab, watchlist-tab, queue-tab
- social actor-list pattern → ActorList component
- movie detail inline sections → community-reviews, viewing-history
2026-07-10 17:38:58 +02:00
89045414cf fmt
Some checks failed
CI / Check / Test (push) Failing after 9m15s
2026-07-10 17:10:59 +02:00
2de6690401 fix: import remote outbox on outbound follow accept (k-ap 0.4.2)
k-ap now emits OutboundFollowAccepted when remote accepts our follow.
Bridge maps it to FollowAccepted domain event. Worker handler looks up
the actor's outbox URL and calls import_remote_outbox. Previously the
accept was silent — no backfill happened, feed stayed empty.
2026-07-10 17:02:14 +02:00
12378c3649 fmt 2026-07-10 16:32:07 +02:00
46b8488b09 refactor: Feed uses SocialIdentity matching, deps From impl, remove get_accepted_following_urls
Feed builds FollowingFilter by matching SocialIdentity::Local/Remote
instead of URL-prefix heuristic. Removed get_accepted_following_urls
from SocialQuery (no longer needed). Added From<&AppState> for deps
structs — 20 construction sites collapsed to one-liners.
2026-07-10 16:31:05 +02:00
7e02f15a85 refactor: consolidate 11 social use cases → SocialCmd/SocialQry enum dispatch
-280 net lines. 11 one-file use cases replaced by execute_command/
execute_query with enum dispatch. Added FollowRejected event (reject
was silently dropping). Command→event mapping now explicit in one match.
2026-07-10 16:26:57 +02:00
d60c47199c fix: FollowTarget enum — actor_url no longer lies about holding a handle
FollowCommand.target is now FollowTarget (Identity|Handle) instead of
SocialIdentity. actor_url field always holds a URL; handles go through
FollowTarget::Handle. Adapter resolves handles explicitly. Type system
prevents misuse in future commands.
2026-07-10 16:18:57 +02:00
2484f1e603 fmt 2026-07-10 16:03:36 +02:00
44d7df33a2 refactor: remove SocialQueryPort — replaced by SocialQuery + FederationAdminQuery
-319 lines. Legacy SocialQueryPort trait, NoopSocialQueryPort,
PanicSocialQueryPort all deleted. Federation repos now implement
FederationAdminQuery (single method). get_users uses FederationAdminQuery.
All other consumers use unified SocialQuery.
2026-07-10 16:02:27 +02:00
7cfa234902 refactor: SocialActor rich queries, migrate remaining handlers, slim SocialQueryPort
SocialQuery returns SocialActor (identity+handle+display_name+avatar_url)
instead of bare SocialIdentity. Migrated get_following_page,
get_followers_page, get_blocked_actors_page to use cases. Moved
get_activity_feed + get_profile from SocialQueryPort to SocialQuery.
Legacy SocialQueryPort remains only for get_users listing.
2026-07-10 15:52:37 +02:00
3ee75305a9 refactor: move ap_service from AppState to AppContext.Services 2026-07-10 15:34:23 +02:00
322e9ee81a feat: unified social identity layer — SocialIdentity, ports, use cases, adapter
SocialIdentity value object (Local|Remote), SocialCommand/SocialQuery
domain ports, 11 CQRS use cases w/ 14 tests, CompositeSocialAdapter
wrapping k_ap, 6 new domain events, handlers migrated from ap_service
to use cases. Closes #12 foundation — AP handlers + legacy cleanup TBD.
2026-07-10 15:29:46 +02:00
96ce5f7d26 docs: domain glossary (16 terms) + ADR-0002 unified social identity
CONTEXT.md expanded with Movie, Person, Review, Rating, WatchMedium,
Watchlist, Goal, WrapUp, User, Follow, Feed, WatchEvent, Import,
ImportProfile, SocialIdentity. ADR-0002 documents wrap-k_ap decision.
2026-07-10 15:29:31 +02:00
6bf4ffc4ab refactor: update rating validation logic — enforce minimum rating value
All checks were successful
CI / Check / Test (push) Successful in 1h5m56s
2026-07-10 13:22:21 +02:00
fa881c3fd1 docs: update architecture.mmd+README — DiaryQuery, GoalCommand/GoalQuery, adapter-common crate
Some checks failed
CI / Check / Test (push) Failing after 21m52s
2026-07-10 05:48:17 +02:00
0eb56c2be6 fmt
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-07-10 05:45:44 +02:00
c224cc6bd2 refactor: extract adapter-common crate — shared sqlx error mapping,
row-to-domain conversions, date/uuid utils

Eliminates 39 map_err copies, consolidates parse_uuid/parse_datetime/
datetime_to_str/format_year_month, extracts 7 shared row-to-domain
conversion functions (movie, review, watchlist, stats, user_summary).
Row structs stay per-adapter (FromRow is db-specific), only conversion
logic is shared. Net -281 lines.
2026-07-10 05:44:42 +02:00
6a9b4e5c00 refactor: LOW+MEDIUM cleanups — CQRS DiaryQuery/GoalCommand+GoalQuery,
test-helpers out of production, decompose get_user_profile_html,
dedup secure_flag, remove vestigial social_query, drop
PersistedImportSession, LoginCommand rename, DeleteAccountDeps,
PersonId macro, ReviewSortBy rename, TUI Command for fs::read,
generic PaginatedResponse<T>, noop ports for production fallbacks
2026-07-10 05:29:02 +02:00
5e0dde656c refactor: LOW cleanups — dedup secure_flag, remove vestigial social_query,
drop PersistedImportSession, rename LoginQuery→LoginCommand, own
DeleteAccountDeps, PersonId via uuid_id! macro, rename SortDirection→
ReviewSortBy, TUI fs::read→Command, generic PaginatedResponse<T>
2026-07-10 05:04:35 +02:00
5266646b0b fix: remove unused watch_event_query binding, prefix dead field for clippy -D warnings 2026-07-10 04:32:54 +02:00
2e32847129 docs+fix: update arch diagram/README/Insomnia, ApiError returns JSON
- architecture.mmd: CQRS trait names, ImageFetcher/RssFeedRenderer
  ports, infra-wiring crate, api-types description
- README: add infra-wiring, note CQRS ports
- Insomnia: add PATCH /api/v1/reviews/:id
- ApiError returns {"error":"..."} JSON not plain text
2026-07-10 03:52:49 +02:00
dee013c7eb refactor: remaining MEDIUM — CQRS splits, DI Deps, profile dedup, event Value, response enum
M1: MovieRepository→MovieCommand/MovieQuery, WatchEventRepository→
WatchEventCommand/WatchEventQuery
M2: goals/ and import/ use Deps structs
M7: extract upload_image helper in update_profile
M8: FederationDeliveryRequested activity_json String→serde_json::Value
M11: UserProfileResponse uses ProfileViewData enum
2026-07-10 03:50:43 +02:00
12da356a40 refactor: fix HIGH+MEDIUM architectural violations from code review
HIGH: fix watch_medium data-loss bug, standardize error handling on
ApiError, fix dep direction (rss/template-askama no longer dep on
application), extract ImageFetcher port (remove reqwest from app layer),
move event construction from save_review to use case, extract
infra-wiring crate (DbPool/EventBusBackend dedup), deduplicate
presentation helpers (encode_error, export streaming, multipart parsing)

MEDIUM: split LocalApContentQuery god-trait 10→3 methods, dedup movie
resolution orchestration, add RemoteActorDto/PersonDto mappers, move
AppConfig to infra-wiring, fix SocialQueryPort Uuid→UserId, replace
stringly-typed api-types with domain enums, move count_reviews_in_year
to StatsRepository, dedup event publisher cfg blocks, extract
should_enrich, move group_by_month to application, dedup
count_local_posts, add FederationFlags Default, TUI input helper +
ShowError rename + typed auth errors, api-types cleanup
(UserSettingsDto/UserProfileBase/PreviewRowData)

102 files changed, -681 lines net
2026-07-10 02:08:39 +02:00
26152660bb fix: show remote user's reviews on their profile (include_remote when user_id set)
Some checks failed
CI / Check / Test (push) Failing after 21m16s
2026-07-10 00:51:33 +02:00
c8f93bdd35 fix: human-readable watch medium labels in classic HTML (Media Server, Physical Media) 2026-07-10 00:44:23 +02:00
081a20ae31 fix: clippy too-many-args on update_remote_review, use RemoteReviewUpdate struct 2026-07-10 00:41:51 +02:00
cde2f5aaae feat: federate watch_medium in AP review objects
- add watch_medium field to ReviewObject (outbound + inbound)
- update_remote_review passes watch_medium to DB
- unknown values silently ignored on ingest
2026-07-10 00:38:29 +02:00
f584bcd724 fix: diary page filters by authenticated user, excludes federated reviews 2026-07-10 00:35:51 +02:00
925f74bb3d feat: auto-grow comment textarea, review detail sheet for full text
- textarea grows as you type instead of fixed 3 rows
- tap truncated comment to open detail sheet with full selectable text
- review detail sheet shows poster, rating, medium, date, full comment
2026-07-10 00:33:49 +02:00
306f4489fd fix: hoist TooltipProvider to app root, remove per-component instances 2026-07-10 00:25:04 +02:00
5d4622e046 feat: tooltip with concrete date on relative timestamps in SPA 2026-07-10 00:22:14 +02:00
2e2adef5e0 fix: profile shows only own reviews, federation display name in community
- diary API accepts user_id filter, profile page passes it
- movie social feed joins ap_remote_actors for handle resolution
- COALESCE prefers actor handle for remote reviews, email for local
2026-07-10 00:19:23 +02:00
206ad44e82 fix: auto-set WatchMedium::MediaServer on webhook watch confirms 2026-07-10 00:07:52 +02:00
8eecd06fb8 docs: update README + architecture for WatchMedium and review editing 2026-07-10 00:05:36 +02:00
29cc68b07c feat: add WatchMedium field, general review editing, configurable deploy
Some checks failed
CI / Check / Test (push) Failing after 27m0s
- WatchMedium enum (cinema/streaming/tv/physical_media/download/media_server/other)
- PATCH /api/v1/reviews/:id partial update (rating, comment, watched_at, watch_medium)
- edit_review use case w/ ownership + remote review guard, best-effort AP Update broadcast
- SPA: icon picker, edit sheet (long-press mobile / pencil desktop), watch medium badge
- shared ReviewFormFields, EditableContextMenu, parse_watched_at/format_watched_at
- deploy.sh parameterized (--features, --tag), CORS allows PATCH
- CONTEXT.md glossary, ADR-0001 general review editing
2026-07-10 00:01:14 +02:00
9794babe06 feat: implement federated profile handling with support for remote actor URLs across multiple components
All checks were successful
CI / Check / Test (push) Successful in 1h7m53s
2026-07-01 00:13:28 +02:00
396 changed files with 11972 additions and 7141 deletions

View File

@@ -57,8 +57,8 @@ jobs:
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GHCR_TOKEN || github.token }}
- name: Docker metadata
id: meta

1
.gitignore vendored
View File

@@ -14,6 +14,7 @@
.worktrees/
.superpowers/
docs/
!docs/adr/
imgs/
.sqlx/

65
CONTEXT.md Normal file
View File

@@ -0,0 +1,65 @@
# Movies Diary
A personal movie diary that tracks what you watch, when, and what you thought about it. Supports federation via ActivityPub.
## Language
**Movie**:
A film in the catalog, identified by title and release year. Optionally linked to an external metadata provider (e.g. TMDb) for enrichment. One Movie record is shared across all users — "Blade Runner (1982)" exists once regardless of how many people review it.
_Avoid_: Film entry, title record
**Person**:
Someone involved in making a movie — actor, director, crew member. Sourced from an external metadata provider and enriched with biographical data. Linked to Movies through cast/crew credits. Not a User — Person is movie-industry people only.
_Avoid_: Celebrity, artist, talent
**Review**:
A single record of watching a movie — captures the rating, optional comment, when it was watched, and how it was watched.
_Avoid_: Diary entry, watch, log entry
**Rating**:
A 15 whole-star score given to a movie in a Review. No half-stars, no zero.
_Avoid_: Score, grade, stars (as a noun for the value itself)
**WatchMedium**:
The channel through which a movie was watched: Cinema, Streaming, TV, PhysicalMedia, Download, MediaServer, or Other.
_Avoid_: Source, format, venue, platform
**Watchlist**:
A user's collection of movies they intend to watch. Each item is a simple bookmark — no priority or ordering. A movie leaves the watchlist implicitly when reviewed, or explicitly when removed.
_Avoid_: Queue, backlog, to-watch list
**Goal**:
A yearly target a user sets — e.g. "watch 50 movies in 2025." Progress is tracked automatically as reviews are logged. Currently only supports movie-count goals, but the model is designed for other goal types in the future.
_Avoid_: Challenge, resolution, target
**WrapUp**:
A generated summary report of viewing activity over a date range — statistics, trends, highlights, top directors/actors/genres. Can be personal (one user) or global (all users). Generated asynchronously. Shown to users as "Year in Review."
_Avoid_: Stats page, recap, summary
**User**:
A registered account with a username, email, and profile (display name, bio, avatar, banner). Can be Standard or Admin.
_Avoid_: Account, member, profile (as a synonym for the whole User)
**SocialIdentity**:
The uniform identifier for anyone involved in a social interaction — either a local User or a remote federated actor. Social commands and queries operate on SocialIdentity so the domain never branches on local vs remote.
_Avoid_: Actor, participant, social user
**Follow**:
A social relationship where one user subscribes to another's activity. Always requires acceptance by the target user. Works identically for local and federated (ActivityPub) users. Once accepted, the followed user's reviews appear in the follower's Feed.
_Avoid_: Subscribe, connect, friend
**Feed**:
A chronological timeline of reviews from users you follow — both local and federated. The main social surface of the app.
_Avoid_: Timeline, activity stream, home
**WatchEvent**:
An automatically detected viewing reported by an external source — currently Jellyfin and Plex via webhook, but conceptually any system that can report "this person watched this movie" (e.g. a cinema ticket service). Arrives in a pending state; the user confirms it (creating a Review) or dismisses it.
_Avoid_: Playback event, webhook event, auto-import
**Import**:
Bulk ingestion of reviews from an external file — Letterboxd CSV, IMDb CSV, or a generic JSON format. The user uploads a file, column mappings are applied, and reviews are created in batch.
_Avoid_: Upload, migration, sync
**ImportProfile**:
A saved set of column-to-field mappings for an Import. Reusable across imports and shareable between users.
_Avoid_: Template, mapping preset, import config

40
Cargo.lock generated
View File

@@ -220,6 +220,17 @@ dependencies = [
"url",
]
[[package]]
name = "adapter-common"
version = "0.1.0"
dependencies = [
"chrono",
"domain",
"sqlx",
"tracing",
"uuid",
]
[[package]]
name = "adler2"
version = "2.0.1"
@@ -293,6 +304,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "api-types"
version = "0.1.0"
dependencies = [
"domain",
"serde",
"utoipa",
"uuid",
@@ -319,8 +331,8 @@ dependencies = [
"domain",
"futures",
"hex",
"infra-wiring",
"rand 0.9.4",
"reqwest 0.13.3",
"serde_json",
"sha2",
"tokio",
@@ -1607,6 +1619,7 @@ dependencies = [
"email_address",
"futures",
"serde",
"serde_json",
"thiserror 2.0.18",
"uuid",
]
@@ -2701,6 +2714,14 @@ dependencies = [
"cfb",
]
[[package]]
name = "infra-wiring"
version = "0.1.0"
dependencies = [
"anyhow",
"sqlx",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -2871,9 +2892,9 @@ dependencies = [
[[package]]
name = "k-ap"
version = "0.4.1"
version = "0.5.0"
source = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
checksum = "03e39c04075b39960c329feba896a16aba37f0863669c28e7106b7cc45a9988d"
checksum = "ab6066cccc6ae8aaa2f6262ac7d471e58930a04be3266a2e367d6bdd8aaaba29"
dependencies = [
"activitypub_federation",
"anyhow",
@@ -2882,9 +2903,11 @@ dependencies = [
"chrono",
"enum_delegate",
"futures",
"paste",
"reqwest 0.13.3",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
"url",
@@ -3874,6 +3897,7 @@ dependencies = [
name = "postgres"
version = "0.1.0"
dependencies = [
"adapter-common",
"anyhow",
"async-stream",
"async-trait",
@@ -3910,6 +3934,7 @@ name = "postgres-federation"
version = "0.1.0"
dependencies = [
"activitypub",
"adapter-common",
"anyhow",
"async-trait",
"chrono",
@@ -3925,6 +3950,7 @@ dependencies = [
name = "postgres-search"
version = "0.1.0"
dependencies = [
"adapter-common",
"async-trait",
"domain",
"sqlx",
@@ -3982,6 +4008,7 @@ dependencies = [
"http-body-util",
"importer",
"infer",
"infra-wiring",
"jellyfin",
"metadata",
"nats",
@@ -4587,7 +4614,6 @@ dependencies = [
name = "rss"
version = "0.1.0"
dependencies = [
"application",
"domain",
"rss 2.0.13",
]
@@ -5154,6 +5180,7 @@ dependencies = [
name = "sqlite"
version = "0.1.0"
dependencies = [
"adapter-common",
"anyhow",
"async-stream",
"async-trait",
@@ -5190,6 +5217,7 @@ name = "sqlite-federation"
version = "0.1.0"
dependencies = [
"activitypub",
"adapter-common",
"anyhow",
"async-trait",
"chrono",
@@ -5206,6 +5234,7 @@ dependencies = [
name = "sqlite-search"
version = "0.1.0"
dependencies = [
"adapter-common",
"async-trait",
"domain",
"sqlx",
@@ -5597,7 +5626,7 @@ dependencies = [
name = "template-askama"
version = "0.1.0"
dependencies = [
"application",
"api-types",
"askama",
"chrono",
"domain",
@@ -7127,6 +7156,7 @@ dependencies = [
"export",
"image-converter",
"importer",
"infra-wiring",
"metadata",
"nats",
"object-storage",

View File

@@ -31,6 +31,8 @@ members = [
"crates/adapters/plex",
"crates/adapters/sqlite-search",
"crates/adapters/postgres-search",
"crates/adapters/adapter-common",
"crates/infra-wiring",
]
resolver = "2"
@@ -91,10 +93,11 @@ plex = { path = "crates/adapters/plex" }
image-converter = { path = "crates/adapters/image-converter" }
sqlite-search = { path = "crates/adapters/sqlite-search" }
postgres-search = { path = "crates/adapters/postgres-search" }
adapter-common = { path = "crates/adapters/adapter-common" }
infra-wiring = { path = "crates/infra-wiring" }
[profile.dev]
debug = 1 # line tables only — still debuggable, much faster linking
split-debuginfo = "unpacked" # macOS: skip dsymutil on every link
[profile.dev.package."*"]
opt-level = 2 # compile deps faster at runtime; paid once, cached after

View File

@@ -44,7 +44,9 @@ COPY crates/tui/Cargo.toml crates/tui/Cargo.toml
COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml
COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml
COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml
COPY crates/adapters/adapter-common/Cargo.toml crates/adapters/adapter-common/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
COPY crates/infra-wiring/Cargo.toml crates/infra-wiring/Cargo.toml
# Stub every crate so cargo can resolve and fetch deps
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \

View File

@@ -47,8 +47,8 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
## Features
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 05 rating
- Immutable append-only viewing ledger (tracks re-watches)
- Log movies with a TMDB/OMDb ID or manual title/year/director, with a 15 rating and optional watch medium (cinema, streaming, TV, physical media, download, media server)
- Edit reviews after the fact — update rating, comment, date, or watch medium via partial PATCH; each watch is still a separate record (re-watches tracked)
- Background poster fetching and storage (local filesystem or S3-compatible)
- Movie enrichment via TMDb — full cast, crew, genres, keywords, runtime, budget/revenue, ratings; fetched automatically on movie discovery and refreshed every 30 days; exposed via `GET /api/v1/movies/{id}/profile`
- Full-text search across movies and people via `GET /api/v1/search` — free-text query plus structured filters (genre, year, person, department, language); backed by SQLite FTS5 or PostgreSQL tsvector + GIN indexes
@@ -60,8 +60,9 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
- Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors
- User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API; account deletion broadcasts AP `Delete` actor activity; `alsoKnownAs` change triggers AP `Move` for account migration
- Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations`
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, watch medium breakdown, rewatches, budget analysis); directors/actors filtered by minimum watch count for statistical relevance; shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January
- Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card)
- Profile trends — top directors, genre breakdown, rating distribution histogram, watch medium breakdown, monthly activity chart; all computed from the user's review history
- CSV and JSON diary export
- File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports
- REST API v1 (`/api/v1/`) with full feature parity with the HTML interface
@@ -88,18 +89,20 @@ Open `http://localhost:3000`. The HTTP server and background worker start togeth
Hexagonal (Ports & Adapters) with Domain-Driven Design:
```
api-types — shared REST API request/response DTOs (Serialize/Deserialize + utoipa schemas); used by presentation and tui
domain — pure types and trait definitions, no external deps
application — use cases (commands + queries), business logic orchestration; handlers delegate here for all domain logic
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
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
worker — standalone worker binary (event consumer, poster sync, federation)
adapters/
adapter-common — shared row-to-domain conversions, sqlx error mapping, date/uuid parsing utils
auth — JWT issuance and validation (Argon2 passwords)
sqlite — SQLite repository + connection factory
postgres — PostgreSQL repository + connection factory
metadata — TMDB / OMDb HTTP client
poster-fetcher — downloads poster images
image-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
object-storage — stores images (posters + user avatars) on local filesystem or S3-compatible storage
poster-sync — event handler: triggers poster fetch+store on MovieDiscovered
image-converter — optional background worker: converts stored images to AVIF or WebP; backfills existing images via a 24h periodic job
tmdb-enrichment — TMDb HTTP client implementing MovieEnrichmentClient and PersonEnrichmentClient; event handlers (MovieEnrichmentHandler, PersonEnrichmentHandler) live in the application layer
@@ -112,6 +115,7 @@ adapters/
event-payload — shared event serialization DTOs (used by all event bus adapters)
sqlite-event-queue — durable polling event queue backed by SQLite
postgres-event-queue — durable polling event queue backed by PostgreSQL
event-publisher — in-memory event channel (used in tests)
nats — NATS Core / JetStream event publisher and consumer
event-publisher — in-memory event channel (used in tests)
activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals

View File

@@ -14,7 +14,7 @@ graph TB
APP_PORTS["ReviewLogger<br/><i>application-layer port</i>"]
subgraph UseCases["Use Cases"]
UC_AUTH["auth<br/>login, register"]
UC_DIARY["diary<br/>log_review, get_diary,<br/>get_activity_feed, export"]
UC_DIARY["diary<br/>log_review, edit_review,<br/>get_diary, get_activity_feed,<br/>export"]
UC_MOVIES["movies<br/>get_movies, get_movie_profile,<br/>enrich_movie, request_enrichment,<br/>sync_poster, reindex_search,<br/>merge_duplicates"]
UC_IMPORT["import<br/>create_session, apply_mapping,<br/>execute, profiles"]
UC_USERS["users<br/>get_users, get_profile,<br/>update_profile, delete_account"]
@@ -24,6 +24,7 @@ graph TB
UC_INTEGRATIONS["integrations<br/>webhooks, watch_queue,<br/>confirm, dismiss"]
UC_SEARCH["search<br/>execute"]
UC_PERSON["person<br/>get, get_credits"]
UC_SOCIAL["social<br/>follow, unfollow,<br/>accept, reject, block"]
end
subgraph EventHandlers["Event Handlers"]
EH_MOVIE["MovieEnrichmentHandler<br/><i>on MovieEnrichmentRequested</i>"]
@@ -50,7 +51,7 @@ graph TB
direction TB
subgraph Models["Models"]
M_MOVIE["Movie, MovieSummary,<br/>MovieProfile"]
M_REVIEW["Review, DiaryEntry,<br/>FeedEntry"]
M_REVIEW["Review, ReviewEdit,<br/>DiaryEntry, FeedEntry"]
M_USER["User, UserSummary"]
M_PERSON["Person, PersonId,<br/>PersonCredits"]
M_WATCHLIST["WatchlistEntry,<br/>WatchEvent"]
@@ -59,25 +60,30 @@ graph TB
M_SEARCH["SearchQuery,<br/>SearchResults"]
end
subgraph Ports["Port Traits (Interfaces)"]
P_REPOS["MovieRepository<br/>ReviewRepository<br/>DiaryRepository<br/>UserRepository<br/>WatchlistRepository<br/>WatchEventRepository<br/>WebhookTokenRepository<br/>ImportSessionRepository<br/>MovieProfileRepository<br/>WrapUpRepository<br/>GoalRepository<br/>UserSettingsRepository<br/>MovieDeduplicator"]
P_SERVICES["AuthService<br/>MetadataClient<br/>PosterFetcherClient<br/>ObjectStorage<br/>EventPublisher<br/>EventConsumer<br/>PasswordHasher<br/>DiaryExporter<br/>DocumentParser"]
P_SEARCH["SearchPort<br/>SearchCommand<br/>PersonQuery<br/>PersonCommand"]
P_FEDERATION["SocialQueryPort<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
P_REPOS["MovieCommand / MovieQuery<br/>ReviewRepository<br/>DiaryQuery / StatsRepository<br/>UserRepository / UserProfileFieldsRepository<br/>WatchlistRepository<br/>WatchEventCommand / WatchEventQuery<br/>WebhookTokenRepository<br/>ImportSessionRepository / ImportProfileRepository<br/>MovieProfileRepository<br/>WrapUpRepository / WrapUpStatsQuery<br/>GoalCommand / GoalQuery<br/>UserSettingsRepository / RefreshSessionRepository<br/>MovieDeduplicator"]
P_SERVICES["AuthService<br/>MetadataClient / MovieEnrichmentClient<br/>PersonEnrichmentClient<br/>PosterFetcherClient<br/>ImageFetcher / ObjectStorage<br/>EventPublisher / EventConsumer<br/>PasswordHasher<br/>DiaryExporter / DocumentParser<br/>RssFeedRenderer / MediaServerParser"]
P_SEARCH["SearchPort / SearchCommand<br/>PersonQuery / PersonCommand<br/>FederatedProfileQuery"]
P_FEDERATION["SocialCommand / SocialQuery<br/>FederationAdminQuery<br/>LocalApContentQuery<br/>RemoteWatchlistRepository<br/>RemoteGoalRepository"]
end
subgraph DomainServices["Services (pure, no I/O)"]
DS_WRAPUP["WrapUpAnalyzer<br/><i>build_report, compute_*</i>"]
DS_REVIEW["ReviewHistoryAnalyzer<br/><i>rating_trend</i>"]
end
EVENTS["DomainEvent enum<br/><i>ReviewLogged, MovieDiscovered,<br/>GoalCreated, GoalUpdated,<br/>UserDeleted, UserAccountMoved,<br/>SearchReindexRequested, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>Email, Username, Password, ...</i>"]
VO["Value Objects<br/><i>MovieId, UserId, Rating,<br/>WatchMedium, Email, Username,<br/>Password, ...</i>"]
end
subgraph ApiTypes["api-types (0 domain deps)"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>CastMemberDto, ...</i>"]
subgraph ApiTypes["api-types"]
DTO["DTOs<br/><i>MovieDto, ReviewDto,<br/>FeedEntryDto, UserSummaryDto,<br/>HtmlPageContext, ...</i>"]
end
subgraph InfraWiring["infra-wiring"]
IW["DbPool, EventBusBackend,<br/>AppConfig<br/><i>Shared infra types</i>"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
A_COMMON["adapter-common<br/><i>Shared row conversions,<br/>error mapping, date utils</i>"]
subgraph Storage["Storage"]
A_SQLITE["sqlite<br/><i>SQLite repos</i>"]
A_PG["postgres<br/><i>PostgreSQL repos</i>"]
@@ -87,7 +93,9 @@ graph TB
end
subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream / Core</i>"]
A_SQLITE_QUEUE["sqlite-event-queue<br/><i>Polling, dead-letter</i>"]
A_PG_QUEUE["postgres-event-queue<br/><i>Polling, dead-letter</i>"]
A_EVT_PUB["event-publisher<br/><i>In-memory (tests)</i>"]
A_PAYLOAD["event-payload<br/><i>Serde (de)serialization</i>"]
end
subgraph External["External Services"]

View File

@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
k-ap = { version = "0.4.1", registry = "gitea" }
k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }

View File

@@ -1,8 +1,7 @@
use std::sync::Arc;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use k_ap::{ApContentReader, ApObjectHandler};
use k_ap::{ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::{
@@ -21,10 +20,9 @@ impl ApContentReader for CompositeObjectHandler {
async fn get_local_objects_page(
&self,
user_id: uuid::Uuid,
before: Option<DateTime<Utc>>,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<Utc>)>> {
// Fetch from all three sources (watchlist/goals return all, reviews use DB pagination)
) -> anyhow::Result<Vec<LocalObject>> {
let fetch_limit = limit * 3;
let reviews = self
.review
@@ -39,16 +37,15 @@ impl ApContentReader for CompositeObjectHandler {
.get_local_objects_page(user_id, None, usize::MAX)
.await?;
let mut all: Vec<(Url, serde_json::Value, DateTime<Utc>)> = Vec::new();
let mut all: Vec<LocalObject> = Vec::new();
all.extend(reviews);
all.extend(watchlist);
all.extend(goals);
// Apply before filter and sort descending by timestamp
if let Some(before_ts) = before {
all.retain(|(_, _, ts)| *ts < before_ts);
all.retain(|obj| obj.published_at < before_ts);
}
all.sort_by_key(|b| std::cmp::Reverse(b.2));
all.sort_by_key(|obj| std::cmp::Reverse(obj.published_at));
all.truncate(limit);
Ok(all)
}

View File

@@ -4,7 +4,10 @@ use domain::ports::EventHandler;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{LocalApContentQuery, UserFederationSettingsQuery},
ports::{
GoalQuery, LocalApContentQuery, MovieQuery, ReviewRepository, StatsRepository,
UserFederationSettingsQuery,
},
value_objects::{MovieId, ReviewId, UserId},
};
use std::sync::Arc;
@@ -17,20 +20,33 @@ use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
}
impl ActivityPubEventHandler {
#[allow(clippy::too_many_arguments)]
pub fn new(
ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>,
review_repo: Arc<dyn ReviewRepository>,
movie_repo: Arc<dyn MovieQuery>,
goal_repo: Arc<dyn GoalQuery>,
stats_repo: Arc<dyn StatsRepository>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String,
) -> Self {
Self {
ap_service,
content_query,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
base_url,
}
@@ -92,12 +108,8 @@ impl EventHandler for ActivityPubEventHandler {
let inbox: url::Url = inbox_url
.parse()
.map_err(|e| DomainError::InfrastructureError(format!("bad inbox URL: {e}")))?;
let activity: serde_json::Value =
serde_json::from_str(activity_json).map_err(|e| {
DomainError::InfrastructureError(format!("bad activity JSON: {e}"))
})?;
self.ap_service
.deliver_to_inbox(inbox, activity, *signing_actor_id)
.deliver_to_inbox(inbox, activity_json.clone(), *signing_actor_id)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
@@ -157,16 +169,12 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -175,7 +183,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.content_query
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
@@ -209,7 +217,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
let year = review.watched_at().year() as u16;
@@ -227,16 +235,12 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? {
let review = match self.review_repo.get_review_by_id(review_id).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -245,7 +249,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let movie = self
.content_query
.movie_repo
.get_movie_by_id(review.movie_id())
.await
.ok()
@@ -279,7 +283,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
@@ -310,11 +314,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.watchlist {
return Ok(());
}
@@ -324,7 +324,7 @@ impl ActivityPubEventHandler {
let actor = actor_url(&self.base_url, user_id.value());
let poster_url = self
.content_query
.movie_repo
.get_movie_by_id(movie_id)
.await
.ok()
@@ -349,7 +349,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
@@ -373,7 +373,7 @@ impl ActivityPubEventHandler {
.get_local_reviews_for_movie(movie_id)
.await?;
let movie = self.content_query.get_movie_by_id(movie_id).await?;
let movie = self.movie_repo.get_movie_by_id(movie_id).await?;
let movie = match movie {
Some(m) => m,
None => return Ok(()),
@@ -393,11 +393,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.reviews {
continue;
}
@@ -420,7 +416,7 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
@@ -436,23 +432,24 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let Some((goal, current)) = self
.content_query
.get_goal_with_progress(user_id, year)
let Some(goal) = self
.goal_repo
.find_by_user_and_year(user_id, year)
.await
.ok()
.flatten()
else {
return Ok(());
};
let current = self
.stats_repo
.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 obj = goal_to_ap_object(
@@ -465,7 +462,7 @@ impl ActivityPubEventHandler {
);
let json = serde_json::to_value(obj)?;
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
Ok(())
}
@@ -481,21 +478,14 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}
let current = self
.content_query
.get_goal_with_progress(user_id, year)
.stats_repo
.count_reviews_in_year(user_id, year)
.await
.ok()
.flatten()
.map(|(_, c)| c)
.unwrap_or(0);
let ap_id = goal_url(&self.base_url, user_id.value(), year);
@@ -504,11 +494,11 @@ impl ActivityPubEventHandler {
let json = serde_json::to_value(obj)?;
if is_create {
self.ap_service
.broadcast_create_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_create(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
} else {
self.ap_service
.broadcast_update_note(user_id.value(), json, ApVisibility::Public, vec![])
.broadcast_update(user_id.value(), json, ApVisibility::Public, vec![])
.await?;
}
Ok(())
@@ -519,11 +509,7 @@ impl ActivityPubEventHandler {
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::models::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
.unwrap_or_default();
if !flags.goals {
return Ok(());
}

View File

@@ -33,22 +33,41 @@ impl k_ap::EventPublisher for FederationEventBridge {
inbox,
activity,
signing_actor_id,
} => {
let json = serde_json::to_string(&activity)
.map_err(|e| anyhow::anyhow!("serialize activity: {e}"))?;
self.domain_publisher
.publish(&DomainEvent::FederationDeliveryRequested {
inbox_url: inbox.to_string(),
activity_json: json,
signing_actor_id,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
} => self
.domain_publisher
.publish(&DomainEvent::FederationDeliveryRequested {
inbox_url: inbox.to_string(),
activity_json: activity,
signing_actor_id,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string())),
FederationEvent::DeliveryFailed { inbox, error, .. } => {
tracing::warn!(inbox = %inbox, error = %error, "federation delivery failed permanently");
Ok(())
}
FederationEvent::OutboundFollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
} => {
let identity = domain::value_objects::SocialIdentity::Remote {
actor_url: remote_actor_url,
};
self.domain_publisher
.publish(&DomainEvent::FollowAccepted {
owner: UserId::from_uuid(local_user_id),
requester: identity,
})
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
if let Some(outbox) = outbox_url {
tracing::info!(outbox = %outbox, "importing remote outbox after follow accepted");
// Handled by FollowBackfillHandler reacting to FollowAccepted
}
Ok(())
}
}
}
}

View File

@@ -4,10 +4,10 @@ use async_trait::async_trait;
use chrono::DateTime;
use domain::{
models::RemoteGoalEntry,
ports::{LocalApContentQuery, RemoteGoalRepository},
ports::{GoalQuery, RemoteGoalRepository},
value_objects::UserId,
};
use k_ap::{ApContentReader, ApObjectHandler};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{GoalObject, goal_to_ap_object};
@@ -15,7 +15,7 @@ use crate::urls::{actor_url, goal_url};
pub struct GoalObjectHandler {
pub remote_goal_repo: Arc<dyn RemoteGoalRepository>,
pub content_query: Arc<dyn LocalApContentQuery>,
pub goal_repo: Arc<dyn GoalQuery>,
pub base_url: String,
}
@@ -26,15 +26,16 @@ impl ApContentReader for GoalObjectHandler {
user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id);
let goals = self
.content_query
.list_goals_for_user(&uid)
.goal_repo
.list_for_user(&uid)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, 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());
@@ -47,7 +48,15 @@ impl ApContentReader for GoalObjectHandler {
0,
&self.base_url,
);
results.push((ap_id, serde_json::to_value(obj)?, published));
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}

View File

@@ -6,6 +6,7 @@ pub mod objects;
pub mod port;
pub mod remote_review_repository;
pub mod review_handler;
pub mod social_adapter;
pub(crate) mod urls;
pub mod user_adapter;
pub mod watchlist_handler;
@@ -17,24 +18,27 @@ pub const INSTANCE_ACTOR_ID: uuid::Uuid =
pub use k_ap::{
ActivityPubService, ActivityRepository, ActorRepository, ApContentReader, ApFederationConfig,
ApObjectHandler, ApUser, ApUserRepository, BlocklistRepository, FederationData,
FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
FollowRepository, Follower, FollowerStatus, FollowingStatus, LocalObject, RemoteActor,
};
pub use event_handler::ActivityPubEventHandler;
pub use port::{ActivityPubPort, NoopActivityPubService};
pub use remote_review_repository::RemoteReviewRepository;
pub use remote_review_repository::{RemoteReviewRepository, RemoteReviewUpdate};
pub use review_handler::ReviewObjectHandler;
pub use social_adapter::CompositeSocialAdapter;
pub use user_adapter::DomainUserRepoAdapter;
pub type FederationRepos = (
std::sync::Arc<dyn ActivityRepository>,
std::sync::Arc<dyn FollowRepository>,
std::sync::Arc<dyn ActorRepository>,
std::sync::Arc<dyn BlocklistRepository>,
std::sync::Arc<dyn domain::ports::SocialQueryPort>,
std::sync::Arc<dyn RemoteReviewRepository>,
std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
);
pub struct FederationRepos {
pub activity: std::sync::Arc<dyn ActivityRepository>,
pub follow: std::sync::Arc<dyn FollowRepository>,
pub actor: std::sync::Arc<dyn ActorRepository>,
pub blocklist: std::sync::Arc<dyn BlocklistRepository>,
pub admin_query: std::sync::Arc<dyn domain::ports::FederationAdminQuery>,
pub review_store: std::sync::Arc<dyn RemoteReviewRepository>,
pub remote_watchlist: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
}
pub struct ActivityPubWire {
pub service: std::sync::Arc<dyn ActivityPubPort>,
@@ -51,8 +55,15 @@ pub struct ActivityPubDeps {
pub remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub movie_repo: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary_repo: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub goal_repo: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub stats_repo: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub follow_command: std::sync::Arc<dyn domain::ports::FollowCommand>,
pub follow_query: std::sync::Arc<dyn domain::ports::FollowQuery>,
pub base_url: String,
pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
@@ -68,14 +79,23 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_watchlist_repo,
remote_goal_repo,
local_ap_content,
movie_repo,
review_repo,
diary_repo,
goal_repo,
stats_repo,
user_repo,
federation_settings,
follow_command: _,
follow_query: _,
base_url,
allow_registration,
event_publisher,
} = deps;
let review_handler = std::sync::Arc::new(ReviewObjectHandler {
content_query: std::sync::Arc::clone(&local_ap_content),
movie_repo: std::sync::Arc::clone(&movie_repo),
diary_repo,
review_store,
event_publisher: std::sync::Arc::clone(&event_publisher),
base_url: base_url.clone(),
@@ -87,7 +107,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
});
let goal_handler = std::sync::Arc::new(goal_handler::GoalObjectHandler {
remote_goal_repo,
content_query: std::sync::Arc::clone(&local_ap_content),
goal_repo: std::sync::Arc::clone(&goal_repo),
base_url: base_url.clone(),
});
let composite = std::sync::Arc::new(composite_handler::CompositeObjectHandler {
@@ -127,6 +147,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
.event_publisher(fed_event_bridge)
.allow_registration(allow_registration)
.software_name("movies-diary")
.nodeinfo_metadata(serde_json::json!({
"nodeName": "movies-diary",
"nodeDescription": "A federated movie diary"
}))
.debug(federation_debug)
.build()
.await?,
@@ -136,6 +160,10 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete),
local_ap_content,
review_repo,
movie_repo,
goal_repo,
stats_repo,
federation_settings,
base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -1,11 +1,17 @@
use chrono::{DateTime, Utc};
use k_ap::AS_PUBLIC;
use k_ap::NoteType;
use serde::{Deserialize, Serialize};
use url::Url;
use domain::models::Review;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub(crate) enum ActivityStreamsType {
#[default]
Note,
Article,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ApAttachment {
@@ -34,7 +40,7 @@ pub(crate) fn normalize_hashtag(title: &str) -> String {
#[serde(rename_all = "camelCase")]
pub struct ReviewObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
@@ -49,6 +55,8 @@ pub struct ReviewObject {
pub(crate) rating: u8,
pub(crate) comment: Option<String>,
pub(crate) watched_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub(crate) watch_medium: Option<String>,
/// Discriminator so Movies Diary instances detect this as a review Note.
#[serde(default)]
pub(crate) review: bool,
@@ -102,12 +110,12 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
let tag = vec![
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/moviesdiary", &base_url)).expect("valid base_url"),
href: Url::parse(&format!("{}/tags/moviesdiary", base_url)).expect("valid base_url"),
name: "#MoviesDiary".to_string(),
},
ApHashtag {
kind: "Hashtag".to_string(),
href: Url::parse(&format!("{}/tags/{}", &base_url, normalized.to_lowercase()))
href: Url::parse(&format!("{}/tags/{}", base_url, normalized.to_lowercase()))
.expect("valid base_url"),
name: format!("#{}", normalized),
},
@@ -123,7 +131,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
};
ReviewObject {
kind: NoteType::default(),
kind: ActivityStreamsType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
@@ -135,6 +143,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
rating: review.rating().value(),
comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
watch_medium: review.watch_medium().map(|wm| wm.to_string()),
review: true,
attachment,
tag,
@@ -147,7 +156,7 @@ pub fn review_to_ap_object(review: &Review, input: ReviewApInput) -> ReviewObjec
#[serde(rename_all = "camelCase")]
pub struct WatchlistObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
@@ -215,7 +224,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
];
WatchlistObject {
kind: NoteType::default(),
kind: ActivityStreamsType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,
@@ -237,7 +246,7 @@ pub fn watchlist_to_ap_object(input: WatchlistApInput) -> WatchlistObject {
#[serde(rename_all = "camelCase")]
pub struct GoalObject {
#[serde(rename = "type")]
pub(crate) kind: NoteType,
pub(crate) kind: ActivityStreamsType,
pub(crate) id: Url,
pub(crate) attributed_to: Url,
pub(crate) content: String,
@@ -274,7 +283,7 @@ pub fn goal_to_ap_object(
}];
GoalObject {
kind: NoteType::default(),
kind: ActivityStreamsType::default(),
id: ap_id,
attributed_to: actor_url.clone(),
content,

View File

@@ -6,9 +6,6 @@ use k_ap::{ActivityPubService, BlockedDomain, RemoteActor};
#[async_trait]
pub trait ActivityPubPort: Send + Sync {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String>;
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize>;
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()>;
async fn unfollow(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn accept_follower(
@@ -22,8 +19,6 @@ pub trait ActivityPubPort: Send + Sync {
remote_actor_url: &str,
) -> anyhow::Result<()>;
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>>;
async fn get_accepted_followers(&self, local_user_id: Uuid)
-> anyhow::Result<Vec<RemoteActor>>;
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn block_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
async fn unblock_actor(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()>;
@@ -54,15 +49,6 @@ impl ActivityPubPort for ActivityPubService {
async fn actor_json(&self, user_id: &str) -> anyhow::Result<String> {
self.actor_json(user_id).await
}
async fn count_following(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_following(local_user_id).await
}
async fn count_accepted_followers(&self, local_user_id: Uuid) -> anyhow::Result<usize> {
self.count_accepted_followers(local_user_id).await
}
async fn get_pending_followers(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_pending_followers(local_user_id).await
}
async fn follow(&self, local_user_id: Uuid, handle: &str) -> anyhow::Result<()> {
self.follow(local_user_id, handle).await
}
@@ -86,12 +72,6 @@ impl ActivityPubPort for ActivityPubService {
async fn get_following(&self, local_user_id: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
self.get_following(local_user_id).await
}
async fn get_accepted_followers(
&self,
local_user_id: Uuid,
) -> anyhow::Result<Vec<RemoteActor>> {
self.get_accepted_followers(local_user_id).await
}
async fn remove_follower(&self, local_user_id: Uuid, actor_url: &str) -> anyhow::Result<()> {
self.remove_follower(local_user_id, actor_url).await
}
@@ -147,15 +127,6 @@ impl ActivityPubPort for NoopActivityPubService {
async fn actor_json(&self, _: &str) -> anyhow::Result<String> {
Ok(String::new())
}
async fn count_following(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn count_accepted_followers(&self, _: Uuid) -> anyhow::Result<usize> {
Ok(0)
}
async fn get_pending_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn follow(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}
@@ -171,9 +142,6 @@ impl ActivityPubPort for NoopActivityPubService {
async fn get_following(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn get_accepted_followers(&self, _: Uuid) -> anyhow::Result<Vec<RemoteActor>> {
Ok(vec![])
}
async fn remove_follower(&self, _: Uuid, _: &str) -> anyhow::Result<()> {
Ok(())
}

View File

@@ -3,6 +3,16 @@ use async_trait::async_trait;
use chrono::NaiveDateTime;
use domain::models::Review;
pub struct RemoteReviewUpdate<'a> {
pub ap_id: &'a str,
pub actor_url: &'a str,
pub rating: u8,
pub comment: Option<&'a str>,
pub watched_at: NaiveDateTime,
pub poster_url: Option<&'a str>,
pub watch_medium: Option<&'a str>,
}
#[async_trait]
pub trait RemoteReviewRepository: Send + Sync {
async fn save_remote_review(
@@ -17,15 +27,7 @@ pub trait RemoteReviewRepository: Send + Sync {
async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()>;
async fn update_remote_review(
&self,
ap_id: &str,
actor_url: &str,
rating: u8,
comment: Option<&str>,
watched_at: NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()>;
async fn update_remote_review(&self, update: RemoteReviewUpdate<'_>) -> Result<()>;
async fn delete_by_actor(&self, actor_url: &str) -> Result<()>;
}

View File

@@ -4,10 +4,10 @@ use async_trait::async_trait;
use domain::{
events::DomainEvent,
models::ReviewSource,
ports::{EventPublisher, LocalApContentQuery},
ports::{DiaryQuery, EventPublisher, LocalApContentQuery, MovieQuery},
value_objects::{Comment, ExternalMetadataId, MovieId, Rating, ReviewId, UserId},
};
use k_ap::{ApContentReader, ApObjectHandler};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{ReviewApInput, ReviewObject, review_to_ap_object};
@@ -16,6 +16,8 @@ use crate::urls::{actor_url, review_url};
pub struct ReviewObjectHandler {
pub content_query: Arc<dyn LocalApContentQuery>,
pub movie_repo: Arc<dyn MovieQuery>,
pub diary_repo: Arc<dyn DiaryQuery>,
pub review_store: Arc<dyn RemoteReviewRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
pub base_url: String,
@@ -28,7 +30,7 @@ impl ApContentReader for ReviewObjectHandler {
user_id: uuid::Uuid,
before: Option<chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> anyhow::Result<Vec<(url::Url, serde_json::Value, chrono::DateTime<chrono::Utc>)>> {
) -> anyhow::Result<Vec<LocalObject>> {
let domain_user_id = UserId::from_uuid(user_id);
let before_naive = before.map(|dt| dt.naive_utc());
let entries = self
@@ -63,13 +65,22 @@ impl ApContentReader for ReviewObjectHandler {
base_url: self.base_url.clone(),
},
);
results.push((ap_id, serde_json::to_value(obj)?, published));
let follower_cc = format!("{}/followers", actor);
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}
async fn count_local_posts(&self) -> anyhow::Result<u64> {
self.content_query
self.diary_repo
.count_local_posts()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))
@@ -97,13 +108,18 @@ impl ApObjectHandler for ReviewObjectHandler {
let actor_url_str = obj.attributed_to.to_string();
let review_id = ReviewId::generate();
let movie_id = if let Some(ref ext_id) = obj.external_metadata_id {
match self
.content_query
.get_movie_by_external_metadata_id(ext_id)
.await
{
Ok(Some(movie)) => movie.id().clone(),
_ => MovieId::from_uuid(uuid::Uuid::new_v5(
let found = if let Ok(ext_meta_id) = ExternalMetadataId::new(ext_id.clone()) {
self.movie_repo
.get_movie_by_external_id(&ext_meta_id)
.await
.ok()
.flatten()
} else {
None
};
match found {
Some(movie) => movie.id().clone(),
None => MovieId::from_uuid(uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
ext_id.as_bytes(),
)),
@@ -120,6 +136,12 @@ impl ApObjectHandler for ReviewObjectHandler {
));
let rating = Rating::new(obj.rating.min(5))?;
let comment = obj.comment.map(Comment::new).transpose()?;
let watch_medium = obj
.watch_medium
.as_deref()
.map(|s| s.parse())
.transpose()
.unwrap_or(None);
let review = domain::models::Review::from_persistence(domain::models::PersistedReview {
id: review_id,
@@ -132,6 +154,7 @@ impl ApObjectHandler for ReviewObjectHandler {
source: ReviewSource::Remote {
actor_url: actor_url_str,
},
watch_medium,
});
self.review_store
@@ -181,14 +204,15 @@ impl ApObjectHandler for ReviewObjectHandler {
}
self.review_store
.update_remote_review(
ap_id.as_str(),
actor_url.as_str(),
obj.rating.min(5),
obj.comment.as_deref(),
obj.watched_at.naive_utc(),
obj.poster_url.as_deref(),
)
.update_remote_review(crate::remote_review_repository::RemoteReviewUpdate {
ap_id: ap_id.as_str(),
actor_url: actor_url.as_str(),
rating: obj.rating.min(5),
comment: obj.comment.as_deref(),
watched_at: obj.watched_at.naive_utc(),
poster_url: obj.poster_url.as_deref(),
watch_medium: obj.watch_medium.as_deref(),
})
.await?;
Ok(())

View File

@@ -0,0 +1,298 @@
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},
};
use super::ActivityPubPort;
pub struct CompositeSocialAdapter {
ap_service: Arc<dyn ActivityPubPort>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
}
impl CompositeSocialAdapter {
pub fn new(
ap_service: Arc<dyn ActivityPubPort>,
user_repo: Arc<dyn UserRepository>,
follow_command: Arc<dyn FollowCommand>,
follow_query: Arc<dyn FollowQuery>,
base_url: String,
) -> Self {
Self {
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(),
})
}
}
}
}
fn ap_err(e: anyhow::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SocialCommand for CompositeSocialAdapter {
async fn follow(&self, follower: &UserId, target: &FollowTarget) -> Result<(), DomainError> {
let identity = self.resolve_target_identity(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(());
}
let handle = match target {
FollowTarget::Handle(h) => h.clone(),
FollowTarget::Identity(id) => match id {
SocialIdentity::Local(uid) => {
let user = self
.user_repo
.find_by_id(uid)
.await?
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
SocialIdentity::format_local_handle(user.username().value(), &self.base_url)
}
SocialIdentity::Remote { actor_url } => actor_url.clone(),
},
};
self.ap_service
.follow(follower.value(), &handle)
.await
.map_err(ap_err)
}
async fn unfollow(
&self,
follower: &UserId,
target: &SocialIdentity,
) -> Result<(), DomainError> {
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::Remote { .. } => self
.ap_service
.unfollow(follower.value(), &actor_url)
.await
.map_err(ap_err),
}
}
async fn accept_follow(
&self,
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::Remote { .. } => self
.ap_service
.accept_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
}
async fn reject_follow(
&self,
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::Remote { .. } => self
.ap_service
.reject_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
}
async fn remove_follower(
&self,
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::Remote { .. } => self
.ap_service
.remove_follower(owner.value(), &actor_url)
.await
.map_err(ap_err),
}
}
async fn block(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
self.ap_service
.block_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
}
async fn unblock(&self, blocker: &UserId, target: &SocialIdentity) -> Result<(), DomainError> {
let actor_url = self.actor_url_from_identity(target);
self.ap_service
.unblock_actor(blocker.value(), &actor_url)
.await
.map_err(ap_err)
}
}
#[async_trait]
impl SocialQuery for CompositeSocialAdapter {
async fn get_following(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_following(user.value(), &self.base_url)
.await
}
async fn get_followers(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
self.follow_query
.get_followers(user.value(), &self.base_url)
.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
}
async fn count_following(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_following(user.value()).await
}
async fn count_followers(&self, user: &UserId) -> Result<usize, DomainError> {
self.follow_query.count_followers(user.value()).await
}
async fn get_blocked(&self, user: &UserId) -> Result<Vec<SocialActor>, DomainError> {
let actors = self
.ap_service
.get_blocked_actors(user.value())
.await
.map_err(ap_err)?;
Ok(actors
.into_iter()
.map(|a| {
let identity = SocialIdentity::from_actor_url(&a.url, &self.base_url);
SocialActor {
identity,
handle: a.handle,
display_name: a.display_name,
avatar_url: a.avatar_url,
}
})
.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

@@ -29,6 +29,7 @@ fn review_to_ap_object_includes_two_hashtags() {
created_at: NaiveDateTime::parse_from_str("2024-01-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
watch_medium: None,
});
let obj = review_to_ap_object(
&review,
@@ -67,6 +68,7 @@ fn review_to_ap_object_has_public_addressing() {
created_at: NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S")
.unwrap(),
source: ReviewSource::Local,
watch_medium: None,
});
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object(

View File

@@ -7,7 +7,7 @@ use domain::{
ports::{LocalApContentQuery, RemoteWatchlistRepository},
value_objects::UserId,
};
use k_ap::{ApContentReader, ApObjectHandler};
use k_ap::{AS_PUBLIC, ApContentReader, ApObjectHandler, LocalObject};
use url::Url;
use crate::objects::{WatchlistApInput, WatchlistObject, watchlist_to_ap_object};
@@ -26,7 +26,7 @@ impl ApContentReader for WatchlistObjectHandler {
user_id: uuid::Uuid,
_before: Option<DateTime<chrono::Utc>>,
_limit: usize,
) -> anyhow::Result<Vec<(Url, serde_json::Value, DateTime<chrono::Utc>)>> {
) -> anyhow::Result<Vec<LocalObject>> {
let uid = UserId::from_uuid(user_id);
let entries = self
.content_query
@@ -35,6 +35,7 @@ impl ApContentReader for WatchlistObjectHandler {
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let actor = actor_url(&self.base_url, 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());
@@ -54,7 +55,15 @@ impl ApContentReader for WatchlistObjectHandler {
added_at: published,
base_url: self.base_url.clone(),
});
results.push((ap_id, serde_json::to_value(obj)?, published));
results.push(LocalObject {
ap_id,
object: serde_json::to_value(obj)?,
published_at: published,
to: vec![AS_PUBLIC.to_string()],
cc: vec![follower_cc.clone()],
bto: vec![],
bcc: vec![],
});
}
Ok(results)
}

View File

@@ -0,0 +1,11 @@
[package]
name = "adapter-common"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
sqlx = { workspace = true }
tracing = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,220 @@
use domain::{
errors::DomainError,
models::{
Movie, MovieStats, MovieSummary, PersistedReview, Review, ReviewSource, UserSummary,
WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username, WatchlistEntryId,
},
};
/// Map a [`sqlx::Error`] to a [`DomainError::InfrastructureError`], logging the
/// underlying database error at `error` level.
pub fn map_sqlx_error(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
/// Parse a string as a UUID, returning a [`DomainError`] on failure.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, DomainError> {
uuid::Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
/// Parse a `%Y-%m-%d %H:%M:%S` string into a [`chrono::NaiveDateTime`].
pub fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
/// Format a [`chrono::NaiveDateTime`] as `%Y-%m-%d %H:%M:%S`.
pub fn datetime_to_str(dt: &chrono::NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
/// Convert a `YYYY-MM` string into a human-readable label like `Jan '24`.
pub fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
// ---------------------------------------------------------------------------
// Shared row-to-domain conversion functions
//
// Each database adapter keeps its own `FromRow` structs (sqlite vs postgres
// derive different impls) but the conversion from parsed row fields into
// domain types is identical. These functions capture that shared logic so
// each adapter's `into_domain()` becomes a one-liner delegation.
// ---------------------------------------------------------------------------
/// Convert raw movie row fields into a [`Movie`] domain object.
pub fn movie_row_to_domain(
id: String,
external_metadata_id: Option<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&id)?);
let external_metadata_id = external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(title)?;
let release_year = ReleaseYear::new(release_year as u16)?;
let poster_path = poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
director,
poster_path,
))
}
/// Convert raw review row fields into a [`Review`] domain object.
#[allow(clippy::too_many_arguments)]
pub fn review_row_to_domain(
id: String,
movie_id: String,
user_id: String,
rating: i64,
comment: Option<String>,
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id)?);
let rating = Rating::new(rating as u8)?;
let comment = comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&watched_at)?;
let created_at = parse_datetime(&created_at)?;
let source = match remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
watch_medium,
}))
}
/// Assemble a [`MovieSummary`] from an already-converted [`Movie`] and extra
/// metadata fields. The caller is responsible for converting genres into a
/// `Vec<String>` (sqlite splits a comma-separated string, postgres receives a
/// `Vec` directly).
pub fn movie_summary_to_domain(
movie: Movie,
genres: Vec<String>,
runtime_minutes: Option<i64>,
original_language: Option<String>,
overview: Option<String>,
collection_name: Option<String>,
) -> MovieSummary {
MovieSummary {
movie,
genres,
runtime_minutes: runtime_minutes.map(|v| v as u32),
original_language,
overview,
collection_name,
}
}
/// Convert raw aggregate stats into a [`MovieStats`] domain object.
pub fn movie_stats_to_domain(
total_count: i64,
avg_rating: Option<f64>,
federated_count: i64,
rating_histogram: [i64; 5],
) -> MovieStats {
MovieStats {
total_count: total_count as u64,
avg_rating,
federated_count: federated_count as u64,
rating_histogram: [
rating_histogram[0] as u64,
rating_histogram[1] as u64,
rating_histogram[2] as u64,
rating_histogram[3] as u64,
rating_histogram[4] as u64,
],
}
}
/// Convert raw user summary row fields into a [`UserSummary`] domain object.
#[allow(clippy::too_many_arguments)]
pub fn user_summary_to_domain(
id: String,
email: String,
username: String,
display_name: Option<String>,
total_movies: i64,
avg_rating: Option<f64>,
avatar_path: Option<String>,
) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&id)?),
Email::new(email)?,
Username::new(username)?,
display_name,
total_movies,
avg_rating,
avatar_path,
))
}
/// Convert raw watchlist entry fields into a [`WatchlistEntry`] domain object.
pub fn watchlist_entry_to_domain(
id: String,
user_id: String,
movie_id: String,
added_at: String,
) -> Result<WatchlistEntry, DomainError> {
Ok(WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&id)?),
user_id: UserId::from_uuid(parse_uuid(&user_id)?),
movie_id: MovieId::from_uuid(parse_uuid(&movie_id)?),
added_at: parse_datetime(&added_at)?,
})
}
/// Convert raw watchlist+movie row fields into a [`WatchlistWithMovie`].
///
/// Takes the watchlist entry fields and a pre-converted [`Movie`].
pub fn watchlist_with_movie_to_domain(entry: WatchlistEntry, movie: Movie) -> WatchlistWithMovie {
WatchlistWithMovie { entry, movie }
}

View File

@@ -4,7 +4,8 @@ use domain::{
events::DomainEvent,
models::{ExternalPersonId, PersonId},
value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, SocialIdentity, UserId,
WrapUpId,
},
};
use serde::{Deserialize, Serialize};
@@ -61,10 +62,40 @@ pub enum EventPayload {
user_id: String,
movie_id: String,
},
FollowRequested {
follower_id: String,
target_kind: String,
target_id: String,
},
FollowAccepted {
local_user_id: String,
remote_actor_url: String,
outbox_url: String,
owner_id: String,
requester_kind: String,
requester_id: String,
},
FollowRejected {
owner_id: String,
requester_kind: String,
requester_id: String,
},
Unfollowed {
follower_id: String,
target_kind: String,
target_id: String,
},
FollowerRemoved {
owner_id: String,
follower_kind: String,
follower_id: String,
},
ActorBlocked {
blocker_id: String,
target_kind: String,
target_id: String,
},
ActorUnblocked {
blocker_id: String,
target_kind: String,
target_id: String,
},
BackfillFollower {
owner_user_id: String,
@@ -72,7 +103,7 @@ pub enum EventPayload {
},
FederationDeliveryRequested {
inbox_url: String,
activity_json: String,
activity_json: serde_json::Value,
signing_actor_id: String,
},
WatchEventIngested {
@@ -136,7 +167,13 @@ impl EventPayload {
EventPayload::ImageStored { .. } => "ImageStored",
EventPayload::WatchlistEntryAdded { .. } => "WatchlistEntryAdded",
EventPayload::WatchlistEntryRemoved { .. } => "WatchlistEntryRemoved",
EventPayload::FollowRequested { .. } => "FollowRequested",
EventPayload::FollowAccepted { .. } => "FollowAccepted",
EventPayload::FollowRejected { .. } => "FollowRejected",
EventPayload::Unfollowed { .. } => "Unfollowed",
EventPayload::FollowerRemoved { .. } => "FollowerRemoved",
EventPayload::ActorBlocked { .. } => "ActorBlocked",
EventPayload::ActorUnblocked { .. } => "ActorUnblocked",
EventPayload::BackfillFollower { .. } => "BackfillFollower",
EventPayload::FederationDeliveryRequested { .. } => "FederationDeliveryRequested",
EventPayload::WatchEventIngested { .. } => "WatchEventIngested",
@@ -158,6 +195,44 @@ fn parse_uuid(s: &str, field: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s).map_err(|e| DomainError::InfrastructureError(format!("{field}: {e}")))
}
fn identity_to_payload(id: &SocialIdentity) -> (String, String) {
match id {
SocialIdentity::Local(uid) => ("local".into(), uid.value().to_string()),
SocialIdentity::Remote { actor_url } => ("remote".into(), actor_url.clone()),
}
}
fn follow_target_to_payload(target: &domain::value_objects::FollowTarget) -> (String, String) {
match target {
domain::value_objects::FollowTarget::Identity(id) => identity_to_payload(id),
domain::value_objects::FollowTarget::Handle(h) => ("handle".into(), h.clone()),
}
}
fn payload_to_identity(kind: &str, id: String) -> Result<SocialIdentity, DomainError> {
match kind {
"local" => Ok(SocialIdentity::Local(UserId::from_uuid(parse_uuid(
&id, "user_id",
)?))),
"remote" => Ok(SocialIdentity::Remote { actor_url: id }),
other => Err(DomainError::InfrastructureError(format!(
"unknown identity kind: {other}"
))),
}
}
fn payload_to_follow_target(
kind: &str,
id: String,
) -> Result<domain::value_objects::FollowTarget, DomainError> {
match kind {
"handle" => Ok(domain::value_objects::FollowTarget::Handle(id)),
other => Ok(domain::value_objects::FollowTarget::Identity(
payload_to_identity(other, id)?,
)),
}
}
fn parse_ts(ts: i64) -> Result<NaiveDateTime, DomainError> {
chrono::DateTime::from_timestamp(ts, 0)
.map(|dt| dt.naive_utc())
@@ -243,15 +318,62 @@ impl From<&DomainEvent> for EventPayload {
movie_id: movie_id.value().to_string(),
}
}
DomainEvent::FollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
} => EventPayload::FollowAccepted {
local_user_id: local_user_id.value().to_string(),
remote_actor_url: remote_actor_url.clone(),
outbox_url: outbox_url.clone(),
},
DomainEvent::FollowRequested { follower, target } => {
let (kind, id) = follow_target_to_payload(target);
EventPayload::FollowRequested {
follower_id: follower.value().to_string(),
target_kind: kind,
target_id: id,
}
}
DomainEvent::FollowAccepted { owner, requester } => {
let (kind, id) = identity_to_payload(requester);
EventPayload::FollowAccepted {
owner_id: owner.value().to_string(),
requester_kind: kind,
requester_id: id,
}
}
DomainEvent::FollowRejected { owner, requester } => {
let (kind, id) = identity_to_payload(requester);
EventPayload::FollowRejected {
owner_id: owner.value().to_string(),
requester_kind: kind,
requester_id: id,
}
}
DomainEvent::Unfollowed { follower, target } => {
let (kind, id) = identity_to_payload(target);
EventPayload::Unfollowed {
follower_id: follower.value().to_string(),
target_kind: kind,
target_id: id,
}
}
DomainEvent::FollowerRemoved { owner, follower } => {
let (kind, id) = identity_to_payload(follower);
EventPayload::FollowerRemoved {
owner_id: owner.value().to_string(),
follower_kind: kind,
follower_id: id,
}
}
DomainEvent::ActorBlocked { blocker, target } => {
let (kind, id) = identity_to_payload(target);
EventPayload::ActorBlocked {
blocker_id: blocker.value().to_string(),
target_kind: kind,
target_id: id,
}
}
DomainEvent::ActorUnblocked { blocker, target } => {
let (kind, id) = identity_to_payload(target);
EventPayload::ActorUnblocked {
blocker_id: blocker.value().to_string(),
target_kind: kind,
target_id: id,
}
}
DomainEvent::BackfillFollower {
owner_user_id,
follower_inbox_url,
@@ -435,14 +557,61 @@ impl TryFrom<EventPayload> for DomainEvent {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
})
}
EventPayload::FollowRequested {
follower_id,
target_kind,
target_id,
} => Ok(DomainEvent::FollowRequested {
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
target: payload_to_follow_target(&target_kind, target_id)?,
}),
EventPayload::FollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
owner_id,
requester_kind,
requester_id,
} => Ok(DomainEvent::FollowAccepted {
local_user_id: UserId::from_uuid(parse_uuid(&local_user_id, "local_user_id")?),
remote_actor_url,
outbox_url,
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
requester: payload_to_identity(&requester_kind, requester_id)?,
}),
EventPayload::FollowRejected {
owner_id,
requester_kind,
requester_id,
} => Ok(DomainEvent::FollowRejected {
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
requester: payload_to_identity(&requester_kind, requester_id)?,
}),
EventPayload::Unfollowed {
follower_id,
target_kind,
target_id,
} => Ok(DomainEvent::Unfollowed {
follower: UserId::from_uuid(parse_uuid(&follower_id, "follower_id")?),
target: payload_to_identity(&target_kind, target_id)?,
}),
EventPayload::FollowerRemoved {
owner_id,
follower_kind,
follower_id,
} => Ok(DomainEvent::FollowerRemoved {
owner: UserId::from_uuid(parse_uuid(&owner_id, "owner_id")?),
follower: payload_to_identity(&follower_kind, follower_id)?,
}),
EventPayload::ActorBlocked {
blocker_id,
target_kind,
target_id,
} => Ok(DomainEvent::ActorBlocked {
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
target: payload_to_identity(&target_kind, target_id)?,
}),
EventPayload::ActorUnblocked {
blocker_id,
target_kind,
target_id,
} => Ok(DomainEvent::ActorUnblocked {
blocker: UserId::from_uuid(parse_uuid(&blocker_id, "blocker_id")?),
target: payload_to_identity(&target_kind, target_id)?,
}),
EventPayload::BackfillFollower {
owner_user_id,

View File

@@ -61,6 +61,7 @@ fn make_entry_full(
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap(),
None,
)
.unwrap();
DiaryEntry::new(movie, review)

View File

@@ -12,7 +12,13 @@ pub fn event_to_subject(prefix: &str, event: &DomainEvent) -> String {
DomainEvent::ImageStored { .. } => "image.stored",
DomainEvent::WatchlistEntryAdded { .. } => "watchlist.entry.added",
DomainEvent::WatchlistEntryRemoved { .. } => "watchlist.entry.removed",
DomainEvent::FollowRequested { .. } => "follow.requested",
DomainEvent::FollowAccepted { .. } => "follow.accepted",
DomainEvent::FollowRejected { .. } => "follow.rejected",
DomainEvent::Unfollowed { .. } => "follow.unfollowed",
DomainEvent::FollowerRemoved { .. } => "follower.removed",
DomainEvent::ActorBlocked { .. } => "actor.blocked",
DomainEvent::ActorUnblocked { .. } => "actor.unblocked",
DomainEvent::BackfillFollower { .. } => "backfill.follower",
DomainEvent::FederationDeliveryRequested { .. } => "federation.delivery.requested",
DomainEvent::WatchEventIngested { .. } => "watch.event.ingested",

View File

@@ -4,7 +4,11 @@ pub use config::PosterFetcherConfig;
use std::time::Duration;
use async_trait::async_trait;
use domain::{errors::DomainError, ports::PosterFetcherClient, value_objects::PosterUrl};
use domain::{
errors::DomainError,
ports::{ImageFetcher, PosterFetcherClient},
value_objects::PosterUrl,
};
pub struct ReqwestPosterFetcher {
client: reqwest::Client,
@@ -37,8 +41,32 @@ impl PosterFetcherClient for ReqwestPosterFetcher {
}
}
#[async_trait]
impl ImageFetcher for ReqwestPosterFetcher {
async fn fetch_image(&self, url: &str) -> Result<Vec<u8>, DomainError> {
let bytes = self
.client
.get(url)
.send()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.error_for_status()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?
.bytes()
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(bytes.to_vec())
}
}
pub fn create() -> anyhow::Result<std::sync::Arc<dyn domain::ports::PosterFetcherClient>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
}
pub fn create_image_fetcher() -> anyhow::Result<std::sync::Arc<dyn domain::ports::ImageFetcher>> {
Ok(std::sync::Arc::new(ReqwestPosterFetcher::new(
PosterFetcherConfig::from_env(),
)?))
}

View File

@@ -5,14 +5,15 @@ use domain::{
errors::DomainError,
events::DomainEvent,
ports::{
EventHandler, EventPublisher, MetadataClient, MovieRepository, ObjectStorage,
EventHandler, EventPublisher, MetadataClient, MovieCommand, MovieQuery, ObjectStorage,
PosterFetcherClient,
},
value_objects::{ExternalMetadataId, MovieId, PosterPath},
};
pub struct PosterSyncHandler {
movie_repository: Arc<dyn MovieRepository>,
movie_command: Arc<dyn MovieCommand>,
movie_query: Arc<dyn MovieQuery>,
metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>,
object_storage: Arc<dyn ObjectStorage>,
@@ -22,7 +23,8 @@ pub struct PosterSyncHandler {
impl PosterSyncHandler {
pub fn new(
movie_repository: Arc<dyn MovieRepository>,
movie_command: Arc<dyn MovieCommand>,
movie_query: Arc<dyn MovieQuery>,
metadata_client: Arc<dyn MetadataClient>,
poster_fetcher: Arc<dyn PosterFetcherClient>,
object_storage: Arc<dyn ObjectStorage>,
@@ -30,7 +32,8 @@ impl PosterSyncHandler {
max_retries: u32,
) -> Self {
Self {
movie_repository,
movie_command,
movie_query,
metadata_client,
poster_fetcher,
object_storage,
@@ -44,7 +47,7 @@ impl PosterSyncHandler {
movie_id: MovieId,
external_metadata_id: ExternalMetadataId,
) -> Result<(), DomainError> {
let mut movie = match self.movie_repository.get_movie_by_id(&movie_id).await? {
let mut movie = match self.movie_query.get_movie_by_id(&movie_id).await? {
Some(m) => m,
None => {
tracing::warn!("Sync cancelled: Movie {} not found", movie_id.value());
@@ -82,7 +85,7 @@ impl PosterSyncHandler {
let poster_path = PosterPath::new(stored_path)?;
movie.update_poster(poster_path);
self.movie_repository.upsert_movie(&movie).await?;
self.movie_command.upsert_movie(&movie).await?;
if let Err(e) = self
.event_publisher
@@ -115,7 +118,7 @@ impl EventHandler for PosterSyncHandler {
} => {
// Only sync poster if the movie doesn't have one yet
let already_has_poster = self
.movie_repository
.movie_query
.get_movie_by_id(&MovieId::from_uuid(movie_id.value()))
.await?
.map(|m| m.poster_path().is_some())

View File

@@ -12,7 +12,8 @@ sqlx = { version = "0.8.6", features = [
"chrono",
] }
activitypub = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }

View File

@@ -3,7 +3,8 @@ use async_trait::async_trait;
use chrono::Utc;
use k_ap::ActivityRepository;
use super::{PostgresFederationRepository, datetime_to_str};
use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl ActivityRepository for PostgresFederationRepository {

View File

@@ -1,42 +1,41 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor};
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
use sqlx::Row;
use super::{PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor};
use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
use adapter_common::datetime_to_str;
#[async_trait]
impl ActorRepository for PostgresFederationRepository {
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
impl KeypairRepository for PostgresFederationRepository {
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
let uid = user_id.to_string();
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
Ok(row.map(|r| Keypair {
public_key: r.get("public_key"),
private_key: r.get("private_key"),
}))
}
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
let uid = user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
sqlx::query(
"INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz)
ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key",
).bind(&uid).bind(&public_key).bind(&private_key).bind(&created_at).execute(&self.pool).await?;
).bind(&uid).bind(&keypair.public_key).bind(&keypair.private_key).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
}
#[async_trait]
impl RemoteActorCache for PostgresFederationRepository {
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
@@ -67,7 +66,10 @@ impl ActorRepository for PostgresFederationRepository {
.await?;
Ok(row.as_ref().map(|r| pg_remote_actor(r, "url")))
}
}
#[async_trait]
impl AnnounceRepository for PostgresFederationRepository {
async fn add_announce(
&self,
activity_id: &str,

View File

@@ -2,17 +2,16 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie,
},
ports::LocalApContentQuery,
value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,
},
};
use sqlx::{PgPool, Row};
use uuid::Uuid;
pub struct PostgresApContentQuery {
pool: PgPool,
@@ -22,24 +21,11 @@ impl PostgresApContentQuery {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
// ── Local row types ──────────────────────────────────────────────────────────
fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
use adapter_common::{parse_datetime, parse_uuid};
#[derive(sqlx::FromRow)]
struct MovieRow {
@@ -82,6 +68,7 @@ struct ReviewRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl ReviewRow {
@@ -97,6 +84,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -106,6 +94,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium,
}))
}
}
@@ -126,6 +115,7 @@ struct DiaryRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl DiaryRow {
@@ -148,105 +138,17 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
}
}
fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}
async fn count_reviews_in_year(
pool: &PgPool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as u32)
}
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait]
impl LocalApContentQuery for PostgresApContentQuery {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_local_watchlist_for_user(
&self,
user_id: &UserId,
@@ -265,7 +167,7 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter()
.map(|row| {
@@ -323,7 +225,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL
@@ -332,66 +235,10 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(&mid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
FROM reviews WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError> {
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(external_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
async fn get_local_reviews_page(
&self,
user_id: &UserId,
@@ -408,7 +255,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz
@@ -420,14 +268,15 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
} else {
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL
@@ -438,49 +287,8 @@ impl LocalApContentQuery for PostgresApContentQuery {
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 AND year = $2",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = row_to_goal(&r)?;
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
}

View File

@@ -1,13 +1,14 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository};
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
use sqlx::Row;
use super::{PostgresFederationRepository, datetime_to_str};
use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl BlocklistRepository for PostgresFederationRepository {
impl DomainBlocklist for PostgresFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let ts = datetime_to_str(&Utc::now().naive_utc());
sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason")
@@ -47,7 +48,10 @@ impl BlocklistRepository for PostgresFederationRepository {
.await?;
Ok(count > 0)
}
}
#[async_trait]
impl ActorBlocklist for PostgresFederationRepository {
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc());

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::PostgresFederationRepository;
#[async_trait]
impl FederatedProfileQuery for PostgresFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = $1",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -1,18 +1,16 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
use sqlx::Row;
use super::{
PG_ACTOR_COLS, PostgresFederationRepository, datetime_to_str, pg_remote_actor, status_to_str,
str_to_status,
use crate::{
PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status,
};
use adapter_common::datetime_to_str;
#[async_trait]
impl FollowRepository for PostgresFederationRepository {
impl FollowerWriter for PostgresFederationRepository {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
@@ -59,6 +57,25 @@ impl FollowRepository for PostgresFederationRepository {
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowerReader for PostgresFederationRepository {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let q = format!(
@@ -116,22 +133,6 @@ impl FollowRepository for PostgresFederationRepository {
Ok(count as usize)
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
@@ -193,129 +194,4 @@ impl FollowRepository for PostgresFederationRepository {
.map(|row| pg_remote_actor(row, "remote_actor_url"))
.collect())
}
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING")
.bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row)
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT a.outbox_url FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.remote_actor_url = $2",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,106 @@
use crate::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor};
use adapter_common::datetime_to_str;
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
#[async_trait]
impl FollowingWriter for PostgresFederationRepository {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING")
.bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row)
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowingReader for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
}

View File

@@ -0,0 +1,27 @@
use anyhow::Result;
use async_trait::async_trait;
use k_ap::FollowMigration;
use crate::PostgresFederationRepository;
#[async_trait]
impl FollowMigration for PostgresFederationRepository {
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,3 @@
mod followers;
mod following;
mod migration;

View File

@@ -0,0 +1,303 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::PostgresFederationRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl domain::ports::FollowCommand for PostgresFederationRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
VALUES ($1, $2, '', $3::timestamptz, $4)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
)
.bind(&uid)
.bind(target_actor_url)
.bind(&now)
.bind(status_str)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
)
.bind(status_str)
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES ($1, $2, $3, $4::timestamptz, '')
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status",
)
.bind(&uid)
.bind(follower_actor_url)
.bind(status_str)
.bind(&now)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3",
)
.bind(status_str)
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
}
fn social_actor_from_row(row: &sqlx::postgres::PgRow, base_url: &str) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
let username: Option<String> = row.try_get("local_username").ok().flatten();
let display: Option<String> = row.try_get("local_display").ok().flatten();
let avatar: Option<String> = row
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.ok()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
(handle, display, avatar)
}
};
SocialActor {
identity,
handle,
display_name,
avatar_url,
}
}
#[async_trait]
impl domain::ports::FollowQuery for PostgresFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(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))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(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))
.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(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(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))
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_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'",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
}
}

View File

@@ -2,7 +2,9 @@ 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;
@@ -11,14 +13,9 @@ mod watchlist;
pub use ap_content::PostgresApContentQuery;
pub use remote_goals::PostgresRemoteGoalRepository;
use chrono::NaiveDateTime;
use k_ap::{FollowerStatus, RemoteActor};
use sqlx::{PgPool, Row};
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
match status {
FollowerStatus::Pending => "pending",
@@ -75,15 +72,23 @@ impl PostgresFederationRepository {
}
}
pub fn create_federated_profile_query(
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));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,
)
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 _,
}
}

View File

@@ -11,11 +11,6 @@ impl PostgresRemoteGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -38,7 +33,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(&received)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -57,7 +52,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(ap_id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -68,7 +63,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -78,7 +73,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -92,7 +87,7 @@ impl RemoteGoalRepository for PostgresRemoteGoalRepository {
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter()
.map(|r| {

View File

@@ -1,9 +1,10 @@
use activitypub::RemoteReviewRepository;
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use domain::models::{Review, ReviewSource};
use super::{PostgresFederationRepository, datetime_to_str};
use super::PostgresFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl RemoteReviewRepository for PostgresFederationRepository {
@@ -71,35 +72,28 @@ impl RemoteReviewRepository for PostgresFederationRepository {
Ok(())
}
async fn update_remote_review(
&self,
ap_id: &str,
actor_url: &str,
rating: u8,
comment: Option<&str>,
watched_at: chrono::NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()> {
let watched_at_str = datetime_to_str(&watched_at);
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
let watched_at_str = datetime_to_str(&u.watched_at);
sqlx::query(
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz
WHERE ap_id = $4 AND remote_actor_url = $5",
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4
WHERE ap_id = $5 AND remote_actor_url = $6",
)
.bind(rating as i64)
.bind(comment)
.bind(u.rating as i64)
.bind(u.comment)
.bind(&watched_at_str)
.bind(ap_id)
.bind(actor_url)
.bind(u.watch_medium)
.bind(u.ap_id)
.bind(u.actor_url)
.execute(&self.pool)
.await?;
if let Some(url) = poster_url {
if let Some(url) = u.poster_url {
sqlx::query(
"UPDATE movies SET poster_path = $1
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)",
)
.bind(url)
.bind(ap_id)
.bind(actor_url)
.bind(u.ap_id)
.bind(u.actor_url)
.execute(&self.pool)
.await?;
}

View File

@@ -1,25 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort,
};
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::PostgresFederationRepository;
#[async_trait]
impl SocialQueryPort for PostgresFederationRepository {
async fn get_accepted_following_urls(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
let user_id_str = user_id.to_string();
sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
impl FederationAdminQuery for PostgresFederationRepository {
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'",
@@ -33,49 +18,4 @@ impl SocialQueryPort for PostgresFederationRepository {
})
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url FROM ap_followers f JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'",
).bind(&uid).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(
|(url, handle, display_name, avatar_url)| PendingFollowerInfo {
url,
handle,
display_name,
avatar_url,
},
)
.collect())
}
}

View File

@@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
uuid = { workspace = true }
domain = { workspace = true }
adapter-common = { workspace = true }
async-trait = { workspace = true }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] }
uuid = { workspace = true }

View File

@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: PgPool) -> (Arc<dyn SearchCommand>, Arc<dyn S
)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SearchCommand for PostgresSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
@@ -91,7 +87,7 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(&fts_input)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -113,7 +109,7 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(&fts_input)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -127,14 +123,14 @@ impl SearchCommand for PostgresSearchAdapter {
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
EntityType::Person => {
sqlx::query("DELETE FROM people_search WHERE person_id = $1")
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
}
Ok(())
@@ -183,7 +179,7 @@ impl PostgresSearchAdapter {
.bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
} else {
let count: i64 = sqlx::query_scalar(
@@ -196,7 +192,7 @@ impl PostgresSearchAdapter {
.bind(query.filters.year.map(|y| y as i32))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
};
@@ -221,7 +217,7 @@ impl PostgresSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
} else {
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
@@ -238,7 +234,7 @@ impl PostgresSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
};
let items = rows
@@ -290,7 +286,7 @@ impl PostgresSearchAdapter {
.bind(text)
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
};
@@ -316,7 +312,7 @@ impl PostgresSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let mut items = Vec::with_capacity(rows.len());
for row in rows {

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
collections::{PageParams, Paginated},
},
ports::DiaryRepository,
ports::DiaryQuery,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
@@ -22,45 +22,41 @@ impl PostgresDiaryRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
match movie_id {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err),
.map_err(adapter_common::map_sqlx_error),
Some(id) => {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}
}
async fn fetch_all_diary_rows(
&self,
sort: &SortDirection,
sort: &ReviewSortBy,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -72,28 +68,29 @@ impl PostgresDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
sort: &ReviewSortBy,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1
@@ -107,51 +104,63 @@ impl PostgresDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn count_user_diary_entries(
&self,
user_id: &str,
search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
.to_string()
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title ILIKE '%' || $2 || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1"
.to_string()
};
WHERE r.user_id = $1{remote_clause}{search_clause}"
);
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.fetch_one(&self.pool).await.map_err(Self::map_err)
q.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_diary_rows(
&self,
user_id: &str,
sort: &SortDirection,
sort: &ReviewSortBy,
search: Option<&str>,
include_remote: bool,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
let mut p: i32 = 1; // $1 is user_id
let mut p: i32 = 1;
let search_clause = if has_search {
p += 1;
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
@@ -168,13 +177,13 @@ impl PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL{}
ORDER BY {}
LIMIT {} OFFSET {}",
search_clause, order_clause, limit_param, offset_param
WHERE r.user_id = $1{remote_clause}{search_clause}
ORDER BY {order_clause}
LIMIT {limit_param} OFFSET {offset_param}",
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
@@ -185,12 +194,12 @@ impl PostgresDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}
#[async_trait]
impl DiaryRepository for PostgresDiaryRepository {
impl DiaryQuery for PostgresDiaryRepository {
async fn query_diary(
&self,
filter: &DiaryFilter,
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)?
}
(Some(_), Some(_)) => {
@@ -322,7 +339,9 @@ impl DiaryRepository for PostgresDiaryRepository {
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
ELSE COALESCE(u.email, r.user_id) END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
@@ -353,7 +372,10 @@ impl DiaryRepository for PostgresDiaryRepository {
}
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
let total = count_q
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
@@ -361,7 +383,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -386,7 +408,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?;
@@ -394,13 +416,14 @@ impl DiaryRepository for PostgresDiaryRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
remote_actor_url,
watch_medium
FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
@@ -415,7 +438,8 @@ impl DiaryRepository for PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1
@@ -424,7 +448,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
@@ -441,7 +465,8 @@ impl DiaryRepository for PostgresDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url
r.remote_actor_url,
r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1
@@ -452,7 +477,7 @@ impl DiaryRepository for PostgresDiaryRepository {
while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row {
Ok(r) => r.into_domain(),
Err(e) => Err(Self::map_err(e)),
Err(e) => Err(adapter_common::map_sqlx_error(e)),
};
}
})
@@ -475,7 +500,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
.map(MovieStatsRow::into_domain)
}
@@ -492,7 +517,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
@@ -500,12 +525,13 @@ impl DiaryRepository for PostgresDiaryRepository {
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
ELSE COALESCE(u.email, r.user_id) END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
WHERE r.movie_id = $1
ORDER BY r.watched_at DESC
LIMIT $2 OFFSET $3",
@@ -515,7 +541,7 @@ impl DiaryRepository for PostgresDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -535,7 +561,7 @@ impl DiaryRepository for PostgresDiaryRepository {
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(count as u64)
}
}

View File

@@ -2,12 +2,12 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{Goal, GoalType},
ports::GoalRepository,
ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId},
};
use sqlx::{PgPool, Row};
use crate::models::{datetime_to_str, parse_datetime, parse_uuid};
use adapter_common::{datetime_to_str, parse_datetime, parse_uuid};
pub struct PostgresGoalRepository {
pool: PgPool,
@@ -17,15 +17,10 @@ impl PostgresGoalRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl GoalRepository for PostgresGoalRepository {
impl GoalCommand for PostgresGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
@@ -46,7 +41,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -60,7 +55,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
@@ -77,14 +72,17 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&uid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
}
#[async_trait]
impl GoalQuery for PostgresGoalRepository {
async fn find_by_user_and_year(
&self,
user_id: &UserId,
@@ -102,7 +100,7 @@ impl GoalRepository for PostgresGoalRepository {
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| row_to_goal(&r)).transpose()
}
@@ -118,14 +116,10 @@ impl GoalRepository for PostgresGoalRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(

View File

@@ -96,11 +96,6 @@ impl PostgresImportProfileRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -118,7 +113,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
@@ -139,7 +134,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter()
.map(|r| {
@@ -184,7 +179,7 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.bind(&id_str).bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| {
Ok(ImportProfile {
@@ -212,6 +207,6 @@ impl ImportProfileRepository for PostgresImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}

View File

@@ -5,7 +5,6 @@ use domain::{
models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
},
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId},
@@ -203,11 +202,6 @@ impl PostgresImportSessionRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn serialize_session(
s: &ImportSession,
) -> Result<(String, Option<String>, Option<String>), DomainError> {
@@ -267,7 +261,7 @@ impl PostgresImportSessionRepository {
Ok(js.into_iter().map(annotated_from_json).collect())
})
.transpose()?;
Ok(ImportSession::from_persistence(PersistedImportSession {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -282,7 +276,7 @@ impl PostgresImportSessionRepository {
row_results,
created_at,
expires_at,
}))
})
}
}
@@ -302,7 +296,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn get(
@@ -332,7 +326,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| {
Self::deserialize_session(
@@ -360,7 +354,7 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -370,14 +364,14 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()")
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
@@ -388,6 +382,6 @@ impl ImportSessionRepository for PostgresImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}

View File

@@ -39,30 +39,6 @@ pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenReposito
pub use watchlist::PostgresWatchlistRepository;
pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery};
pub(crate) fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> {
sqlx::migrate!("./migrations")
.set_ignore_missing(true)
@@ -79,9 +55,10 @@ pub fn create_profile_fields_repo(
pub struct PostgresWireOutput {
pub pool: PgPool,
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
@@ -91,7 +68,8 @@ pub struct PostgresWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
@@ -114,9 +92,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
user_settings::PostgresUserSettingsRepository::new(pool.clone()),
);
let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone()));
Ok(PostgresWireOutput {
pool: pool.clone(),
movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _,
movie_command: movie_repo.clone() as _,
movie_query: movie_repo as _,
review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _,
diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _,
stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _,
@@ -130,7 +111,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
goal_command: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(postgres_federation::PostgresRemoteGoalRepository::new(

View File

@@ -1,16 +1,11 @@
use chrono::NaiveDateTime;
use adapter_common::{
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
user_summary_to_domain,
};
use domain::{
errors::DomainError,
models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username,
},
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review},
};
use uuid::Uuid;
#[derive(sqlx::FromRow)]
pub(crate) struct MovieRow {
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
impl MovieRow {
pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
let external_metadata_id = self
.external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(self.title)?;
let release_year = ReleaseYear::new(self.release_year as u16)?;
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
poster_path,
))
self.poster_path,
)
}
}
@@ -60,23 +47,22 @@ pub(crate) struct MovieSummaryRow {
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
Ok(MovieSummary {
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
Ok(movie_summary_to_domain(
movie,
genres: self.genres.unwrap_or_default(),
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
original_language: self.original_language,
overview: self.overview,
collection_name: self.collection_name,
})
self.genres.unwrap_or_default(),
self.runtime_minutes,
self.original_language,
self.overview,
self.collection_name,
))
}
}
@@ -90,31 +76,22 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl ReviewRow {
pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
let rating = Rating::new(self.rating as u8)?;
let comment = self.comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&self.watched_at)?;
let created_at = parse_datetime(&self.created_at)?;
let source = match self.remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
}))
review_row_to_domain(
self.id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)
}
}
@@ -134,30 +111,30 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl DiaryRow {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
let review = ReviewRow {
id: self.review_id,
movie_id: self.movie_id,
user_id: self.user_id,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.into_domain()?;
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
let review = review_row_to_domain(
self.review_id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)?;
Ok(DiaryEntry::new(movie, review))
}
}
@@ -178,28 +155,32 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub user_email: String,
}
impl FeedRow {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
review_id: self.review_id,
movie_id: self.movie_id,
user_id: self.user_id,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.into_domain()?;
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
let review = review_row_to_domain(
self.review_id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)?;
let diary = DiaryEntry::new(movie, review);
Ok(FeedEntry::new(diary, self.user_email))
}
}
@@ -218,18 +199,18 @@ pub(crate) struct MovieStatsRow {
impl MovieStatsRow {
pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
federated_count: self.federated_count as u64,
rating_histogram: [
self.rating_1 as u64,
self.rating_2 as u64,
self.rating_3 as u64,
self.rating_4 as u64,
self.rating_5 as u64,
movie_stats_to_domain(
self.total_count,
self.avg_rating,
self.federated_count,
[
self.rating_1,
self.rating_2,
self.rating_3,
self.rating_4,
self.rating_5,
],
}
)
}
}
@@ -245,16 +226,16 @@ pub(crate) struct UserSummaryRow {
}
impl UserSummaryRow {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,
Username::new(self.username)?,
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
user_summary_to_domain(
self.id,
self.email,
self.username,
self.display_name,
self.total_movies,
self.avg_rating,
self.avatar_path,
))
)
}
}
@@ -277,16 +258,20 @@ pub(crate) struct MonthlyRatingRow {
pub count: i64,
}
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
#[derive(sqlx::FromRow)]
pub(crate) struct GenreCountRow {
pub genre: String,
pub count: i64,
}
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
#[derive(sqlx::FromRow)]
pub(crate) struct RatingDistRow {
pub rating: i64,
pub count: i64,
}
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
#[derive(sqlx::FromRow)]
pub(crate) struct WatchMediumCountRow {
pub watch_medium: String,
pub count: i64,
}

View File

@@ -5,7 +5,7 @@ use domain::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::MovieRepository,
ports::{MovieCommand, MovieQuery},
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::PgPool;
@@ -20,67 +20,10 @@ impl PostgresMovieRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl MovieRepository for PostgresMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
impl MovieCommand for PostgresMovieRepository {
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
@@ -107,7 +50,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -118,9 +61,64 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl MovieQuery for PostgresMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn existing_external_ids(
&self,
@@ -136,7 +134,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&vals)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
@@ -159,7 +157,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(&years)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| {
@@ -208,7 +206,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
@@ -223,7 +221,7 @@ impl MovieRepository for PostgresMovieRepository {
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
@@ -247,7 +245,7 @@ impl MovieRepository for PostgresMovieRepository {
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| r.into_domain())
.collect()

View File

@@ -12,11 +12,6 @@ impl PostgresMovieDeduplicator {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -36,7 +31,11 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
let director = canonical.director().map(str::to_string);
let poster = canonical.poster_path().map(|p| p.value().to_string());
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
// 1. Upsert canonical movie record
sqlx::query(
@@ -47,7 +46,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)",
)
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
// 2. Re-point simple FK tables
let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2")
@@ -55,7 +54,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
let watchlist =
@@ -64,7 +63,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2")
@@ -72,7 +71,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
// 3. Re-point movie_profiles (PK — move only if canonical has none)
@@ -81,7 +80,7 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
// 4. Re-point enrichment tables with composite PKs (INSERT … ON CONFLICT DO NOTHING + DELETE)
@@ -95,12 +94,12 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT INTO movie_keywords (movie_id, tmdb_id, name)
@@ -111,43 +110,43 @@ impl MovieDeduplicator for PostgresMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2
ON CONFLICT DO NOTHING",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?;
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2
ON CONFLICT DO NOTHING",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?;
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
sqlx::query("DELETE FROM movies WHERE id = $1")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
tx.commit().await.map_err(Self::map_err)?;
tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
Ok(reviews + watchlist + watch_events + profiles)
}

View File

@@ -29,10 +29,6 @@ pub fn create_person_adapter(pool: PgPool) -> (Arc<dyn PersonCommand>, Arc<dyn P
)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl PersonCommand for PostgresPersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
@@ -56,7 +52,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
Ok(())
}
@@ -89,7 +85,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(batch_size as i64)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64;
@@ -109,7 +105,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(&row.profile_path)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count += 1;
}
Ok((count, has_more))
@@ -137,7 +133,7 @@ impl PersonCommand for PostgresPersonAdapter {
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
@@ -151,7 +147,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -166,7 +162,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -182,7 +178,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
@@ -219,7 +215,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -238,7 +234,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -261,7 +257,7 @@ impl PersonQuery for PostgresPersonAdapter {
.bind(offset as i64)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect())
}
@@ -279,7 +275,7 @@ impl PersonQuery for PostgresPersonAdapter {
)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()

View File

@@ -16,11 +16,6 @@ impl PostgresMovieProfileRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -28,7 +23,11 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> {
let movie_id = p.movie_id.value().to_string();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
r#"INSERT INTO movie_profiles
@@ -61,35 +60,35 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(p.enriched_at)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for g in &p.genres {
sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for k in &p.keywords {
sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING")
.bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for c in &p.cast {
sqlx::query(
"INSERT INTO movie_cast \
@@ -104,14 +103,14 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for cr in &p.crew {
sqlx::query(
"INSERT INTO movie_crew \
@@ -126,10 +125,10 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
tx.commit().await.map_err(Self::map_err)
tx.commit().await.map_err(adapter_common::map_sqlx_error)
}
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
@@ -144,7 +143,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let row = match row {
Some(r) => r,
@@ -159,7 +158,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -171,7 +170,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i32, _>("tmdb_id").unwrap_or(0) as u32,
@@ -186,7 +185,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -204,7 +203,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -257,7 +256,7 @@ impl MovieProfileRepository for PostgresMovieProfileRepository {
.bind(threshold)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()

View File

@@ -16,10 +16,6 @@ impl PostgresRefreshSessionAdapter {
}
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> {
@@ -34,7 +30,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(session.created_at)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -48,7 +44,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(token)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(RefreshSessionRow::into_domain).transpose()
}
@@ -58,7 +54,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(token)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -67,7 +63,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
.bind(user_id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -75,7 +71,7 @@ impl RefreshSessionRepository for PostgresRefreshSessionAdapter {
let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()")
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}

View File

@@ -1,14 +1,14 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource},
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
};
use sqlx::PgPool;
use crate::models::{ReviewRow, datetime_to_str};
use crate::models::ReviewRow;
use adapter_common::datetime_to_str;
pub struct PostgresReviewRepository {
pool: PgPool,
@@ -18,16 +18,11 @@ impl PostgresReviewRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl ReviewRepository for PostgresReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, DomainError> {
async fn save_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let movie_id = review.movie_id().value().to_string();
let user_id = review.user_id().value().to_string();
@@ -41,8 +36,8 @@ impl ReviewRepository for PostgresReviewRepository {
};
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8)",
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium)
VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9)",
)
.bind(&id)
.bind(&movie_id)
@@ -52,17 +47,12 @@ impl ReviewRepository for PostgresReviewRepository {
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.bind(review.watch_medium().map(|wm| wm.to_string()))
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(DomainEvent::ReviewLogged {
review_id: review.id().clone(),
movie_id: review.movie_id().clone(),
user_id: review.user_id().clone(),
rating: review.rating().clone(),
watched_at: *review.watched_at(),
})
Ok(())
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
@@ -71,24 +61,47 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
remote_actor_url,
watch_medium
FROM reviews WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn update_review(&self, review: &Review) -> Result<(), DomainError> {
let id = review.id().value().to_string();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let watch_medium = review.watch_medium().map(|wm| wm.to_string());
sqlx::query(
"UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4 WHERE id = $5",
)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&watch_medium)
.bind(&id)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = $1")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -98,13 +111,14 @@ impl ReviewRepository for PostgresReviewRepository {
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url
remote_actor_url,
watch_medium
FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(ReviewRow::into_domain)
.collect()

View File

@@ -7,8 +7,11 @@ use domain::{
};
use sqlx::PgPool;
use crate::format_year_month;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
use crate::models::{
DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, UserTotalsRow,
WatchMediumCountRow,
};
use adapter_common::format_year_month;
pub struct PostgresStatsRepository {
pool: PgPool,
@@ -19,11 +22,6 @@ impl PostgresStatsRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>(
r#"SELECT COUNT(DISTINCT movie_id) AS total,
@@ -33,7 +31,7 @@ impl PostgresStatsRepository {
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_favorite_director(
@@ -52,7 +50,7 @@ impl PostgresStatsRepository {
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_most_active_month(
@@ -70,7 +68,7 @@ impl PostgresStatsRepository {
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}
@@ -95,34 +93,68 @@ impl StatsRepository for PostgresStatsRepository {
})
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string();
let (rating_rows, director_rows) = tokio::try_join!(
sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows) =
tokio::try_join!(
sqlx::query_as::<_, MonthlyRatingRow>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month,
AVG(rating::float) AS avg_rating,
COUNT(*) AS count
FROM reviews
WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months'
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director AS director, COUNT(*) AS count
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director AS director, COUNT(*) AS count
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 5"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, GenreCountRow>(
"SELECT mg.name AS genre, COUNT(*) AS count
FROM reviews r
INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id
WHERE r.user_id = $1
GROUP BY mg.name
ORDER BY COUNT(*) DESC
LIMIT 5"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, RatingDistRow>(
"SELECT rating, COUNT(*) AS count
FROM reviews
WHERE user_id = $1
GROUP BY rating
ORDER BY rating ASC"
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, WatchMediumCountRow>(
"SELECT watch_medium, COUNT(*) AS count
FROM reviews
WHERE user_id = $1 AND watch_medium IS NOT NULL
GROUP BY watch_medium
ORDER BY COUNT(*) DESC"
)
.bind(&uid)
.fetch_all(&self.pool)
)
.bind(&uid)
.fetch_all(&self.pool)
)
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
@@ -144,10 +176,38 @@ impl StatsRepository for PostgresStatsRepository {
})
.collect();
let top_genres = genre_rows
.into_iter()
.map(|g| domain::models::stats::GenreStat {
genre: g.genre,
count: g.count,
})
.collect();
let rating_distribution = {
let mut dist = [0i64; 5];
for r in &rating_dist_rows {
let idx = (r.rating as usize).saturating_sub(1).min(4);
dist[idx] = r.count;
}
dist
};
let watch_medium_distribution = medium_rows
.into_iter()
.map(|m| domain::models::stats::WatchMediumStat {
medium: m.watch_medium,
count: m.count,
})
.collect();
Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
top_genres,
rating_distribution,
watch_medium_distribution,
})
}
}

View File

@@ -15,11 +15,6 @@ impl PostgresUserSettingsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -33,7 +28,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
match row {
Some(r) => {
@@ -65,7 +60,7 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
.bind(settings.federate_watchlist())
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
@@ -81,7 +76,7 @@ impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
match row {
Some(r) => {

View File

@@ -20,11 +20,6 @@ impl PostgresUserRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_role(s: &str) -> UserRole {
match s {
"admin" => UserRole::Admin,
@@ -76,7 +71,7 @@ impl UserRepository for PostgresUserRepository {
.bind(email_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
.transpose()
@@ -90,7 +85,7 @@ impl UserRepository for PostgresUserRepository {
.bind(username_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref()
.map(|r| Self::row_to_user(r, vec![]))
.transpose()
@@ -130,7 +125,7 @@ impl UserRepository for PostgresUserRepository {
.bind(role)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -140,7 +135,7 @@ impl UserRepository for PostgresUserRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let Some(r) = row else { return Ok(None) };
@@ -197,7 +192,7 @@ impl UserRepository for PostgresUserRepository {
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(UserSummaryRow::into_domain)
.collect()

View File

@@ -2,17 +2,12 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken},
ports::{WatchEventRepository, WebhookTokenRepository},
ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository},
value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId},
};
use sqlx::{PgPool, Row};
use crate::models::{parse_datetime, parse_uuid};
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
use adapter_common::{parse_datetime, parse_uuid};
// ── WatchEventRepository ──────────────────────────────────────────────────────
@@ -27,7 +22,7 @@ impl PostgresWatchEventRepository {
}
#[async_trait]
impl WatchEventRepository for PostgresWatchEventRepository {
impl WatchEventCommand for PostgresWatchEventRepository {
async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> {
let id = event.id().value().to_string();
let user_id = event.user_id().value().to_string();
@@ -52,7 +47,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(event.created_at())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -70,11 +65,46 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
async fn update_status_batch(
&self,
ids: &[WatchEventId],
status: WatchEventStatus,
) -> Result<u64, DomainError> {
if ids.is_empty() {
return Ok(0);
}
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let status_str = status.to_string();
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
.bind(&status_str)
.bind(&id_strs)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
.bind(before)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
#[async_trait]
impl WatchEventQuery for PostgresWatchEventRepository {
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
let uid = user_id.value().to_string();
@@ -91,7 +121,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
@@ -110,7 +140,7 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_watch_event).transpose()
}
@@ -131,29 +161,10 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(&id_strs)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_watch_event).collect()
}
async fn update_status_batch(
&self,
ids: &[WatchEventId],
status: WatchEventStatus,
) -> Result<u64, DomainError> {
if ids.is_empty() {
return Ok(0);
}
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let status_str = status.to_string();
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
.bind(&status_str)
.bind(&id_strs)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
async fn find_duplicate(
&self,
user_id: &UserId,
@@ -171,36 +182,41 @@ impl WatchEventRepository for PostgresWatchEventRepository {
.bind(after)
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(count > 0)
}
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
.bind(before)
.execute(&self.pool)
.await
.map_err(map_err)?;
Ok(result.rows_affected())
}
}
fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result<WatchEvent, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?;
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
let movie_id_str: Option<String> = row.try_get("movie_id").map_err(map_err)?;
let title: String = row.try_get("title").map_err(map_err)?;
let year: Option<i32> = row.try_get("year").map_err(map_err)?;
let ext_id: Option<String> = row.try_get("external_metadata_id").map_err(map_err)?;
let source_str: String = row.try_get("source").map_err(map_err)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
let status_str: String = row.try_get("status").map_err(map_err)?;
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let movie_id_str: Option<String> = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let title: String = row
.try_get("title")
.map_err(adapter_common::map_sqlx_error)?;
let year: Option<i32> = row
.try_get("year")
.map_err(adapter_common::map_sqlx_error)?;
let ext_id: Option<String> = row
.try_get("external_metadata_id")
.map_err(adapter_common::map_sqlx_error)?;
let source_str: String = row
.try_get("source")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let source: WatchEventSource = source_str
.parse()
@@ -262,7 +278,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(token.last_used_at())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -277,7 +293,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(hash)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_webhook_token).transpose()
}
@@ -294,7 +310,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_webhook_token).collect()
}
@@ -308,7 +324,7 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&uid)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!("Webhook token {id_str}")));
@@ -323,20 +339,32 @@ impl WebhookTokenRepository for PostgresWebhookTokenRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result<WebhookToken, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?;
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
let token_hash: String = row.try_get("token_hash").map_err(map_err)?;
let provider_str: String = row.try_get("provider").map_err(map_err)?;
let label: Option<String> = row.try_get("label").map_err(map_err)?;
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
let last_used_str: Option<String> = row.try_get("last_used_at").map_err(map_err)?;
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let token_hash: String = row
.try_get("token_hash")
.map_err(adapter_common::map_sqlx_error)?;
let provider_str: String = row
.try_get("provider")
.map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row
.try_get("label")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let last_used_str: Option<String> = row
.try_get("last_used_at")
.map_err(adapter_common::map_sqlx_error)?;
let provider: WatchEventSource = provider_str
.parse()

View File

@@ -10,7 +10,8 @@ use domain::{
};
use sqlx::{PgPool, Row};
use crate::models::{MovieRow, parse_datetime, parse_uuid};
use crate::models::MovieRow;
use adapter_common::{parse_datetime, parse_uuid};
pub struct PostgresWatchlistRepository {
pool: PgPool,
@@ -20,11 +21,6 @@ impl PostgresWatchlistRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -46,7 +42,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(added_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -61,7 +57,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound(format!(
@@ -85,7 +81,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected() > 0)
}
@@ -114,14 +110,14 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let total: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1")
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -187,7 +183,7 @@ impl WatchlistRepository for PostgresWatchlistRepository {
.bind(&mid)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(count > 0)
}
}

View File

@@ -13,12 +13,7 @@ use domain::{
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::models::{parse_datetime, parse_uuid};
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
use adapter_common::{parse_datetime, parse_uuid};
fn status_to_str(s: &WrapUpStatus) -> &'static str {
match s {
@@ -76,7 +71,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(record.completed_at)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -96,7 +91,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -115,7 +110,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -132,7 +127,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose()
}
@@ -149,7 +144,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect()
}
@@ -163,7 +158,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_record).collect()
}
@@ -190,7 +185,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(end)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.as_ref().map(row_to_record).transpose()
}
@@ -200,7 +195,7 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -213,21 +208,37 @@ impl WrapUpRepository for PostgresWrapUpRepository {
.bind(before)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
fn row_to_record(row: &sqlx::postgres::PgRow) -> Result<WrapUpRecord, DomainError> {
let id_str: String = row.try_get("id").map_err(map_err)?;
let user_id_str: Option<String> = row.try_get("user_id").map_err(map_err)?;
let start_date: NaiveDate = row.try_get("start_date").map_err(map_err)?;
let end_date: NaiveDate = row.try_get("end_date").map_err(map_err)?;
let status_str: String = row.try_get("status").map_err(map_err)?;
let report_json: Option<String> = row.try_get("report_json").map_err(map_err)?;
let error_message: Option<String> = row.try_get("error_message").map_err(map_err)?;
let created_at_str: String = row.try_get("created_at").map_err(map_err)?;
let completed_at_str: Option<String> = row.try_get("completed_at").map_err(map_err)?;
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: Option<String> = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let start_date: NaiveDate = row
.try_get("start_date")
.map_err(adapter_common::map_sqlx_error)?;
let end_date: NaiveDate = row
.try_get("end_date")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let report_json: Option<String> = row
.try_get("report_json")
.map_err(adapter_common::map_sqlx_error)?;
let error_message: Option<String> = row
.try_get("error_message")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let completed_at_str: Option<String> = row
.try_get("completed_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
@@ -278,7 +289,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
"SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \
r.rating, \
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \
r.user_id, \
r.user_id, r.watch_medium, \
p.runtime_minutes, p.budget_usd, p.original_language \
FROM reviews r \
INNER JOIN movies m ON m.id = r.movie_id \
@@ -292,7 +303,10 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
q = q.bind(uid);
}
let rows = q.fetch_all(&self.pool).await.map_err(map_err)?;
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
if rows.is_empty() {
return Ok(vec![]);
@@ -302,7 +316,9 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
let mut movie_ids: Vec<String> = Vec::new();
let mut seen = std::collections::HashSet::new();
for row in &rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?;
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
if seen.insert(mid.clone()) {
movie_ids.push(mid);
}
@@ -318,18 +334,42 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
// 3) Build result
let mut result = Vec::with_capacity(rows.len());
for row in &rows {
let movie_id_str: String = row.try_get("movie_id").map_err(map_err)?;
let title: String = row.try_get("title").map_err(map_err)?;
let release_year: i64 = row.try_get("release_year").map_err(map_err)?;
let director: Option<String> = row.try_get("director").map_err(map_err)?;
let poster_path: Option<String> = row.try_get("poster_path").map_err(map_err)?;
let rating: i64 = row.try_get("rating").map_err(map_err)?;
let watched_at_str: String = row.try_get("watched_at").map_err(map_err)?;
let user_id_str: String = row.try_get("user_id").map_err(map_err)?;
let runtime_minutes: Option<i32> = row.try_get("runtime_minutes").map_err(map_err)?;
let budget_usd: Option<i64> = row.try_get("budget_usd").map_err(map_err)?;
let original_language: Option<String> =
row.try_get("original_language").map_err(map_err)?;
let movie_id_str: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let title: String = row
.try_get("title")
.map_err(adapter_common::map_sqlx_error)?;
let release_year: i64 = row
.try_get("release_year")
.map_err(adapter_common::map_sqlx_error)?;
let director: Option<String> = row
.try_get("director")
.map_err(adapter_common::map_sqlx_error)?;
let poster_path: Option<String> = row
.try_get("poster_path")
.map_err(adapter_common::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let runtime_minutes: Option<i32> = row
.try_get("runtime_minutes")
.map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row
.try_get("budget_usd")
.map_err(adapter_common::map_sqlx_error)?;
let original_language: Option<String> = row
.try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
@@ -354,6 +394,7 @@ impl WrapUpStatsQuery for PostgresWrapUpStatsQuery {
runtime_minutes: runtime_minutes.map(|v| v as u32),
budget_usd,
original_language,
watch_medium,
genres,
keywords,
cast_names,
@@ -383,12 +424,16 @@ async fn fetch_genres_pg(
.bind(movie_ids)
.fetch_all(pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?;
let name: String = row.try_get("name").map_err(map_err)?;
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name);
}
Ok(map)
@@ -404,12 +449,16 @@ async fn fetch_keywords_pg(
.bind(movie_ids)
.fetch_all(pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<String>> = HashMap::new();
for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?;
let name: String = row.try_get("name").map_err(map_err)?;
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(name);
}
Ok(map)
@@ -428,15 +477,25 @@ async fn fetch_cast_pg(
.bind(movie_ids)
.fetch_all(pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let mut map: HashMap<String, Vec<CastEntry>> = HashMap::new();
for row in rows {
let mid: String = row.try_get("movie_id").map_err(map_err)?;
let name: String = row.try_get("name").map_err(map_err)?;
let billing_order: i32 = row.try_get("billing_order").map_err(map_err)?;
let tmdb_person_id: i64 = row.try_get("tmdb_person_id").map_err(map_err)?;
let profile_path: Option<String> = row.try_get("profile_path").map_err(map_err)?;
let mid: String = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let name: String = row
.try_get("name")
.map_err(adapter_common::map_sqlx_error)?;
let billing_order: i32 = row
.try_get("billing_order")
.map_err(adapter_common::map_sqlx_error)?;
let tmdb_person_id: i64 = row
.try_get("tmdb_person_id")
.map_err(adapter_common::map_sqlx_error)?;
let profile_path: Option<String> = row
.try_get("profile_path")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(CastEntry {
name,
billing_order: billing_order as u32,

View File

@@ -6,4 +6,3 @@ edition = "2024"
[dependencies]
rss-feed = { package = "rss", version = "2" }
domain = { workspace = true }
application = { workspace = true }

View File

@@ -1,5 +1,5 @@
use application::ports::RssFeedRenderer;
use domain::models::DiaryEntry;
use domain::ports::RssFeedRenderer;
use rss_feed::{ChannelBuilder, GuidBuilder, ItemBuilder};
pub struct RssAdapter {

View File

@@ -6,7 +6,8 @@ edition = "2024"
[dependencies]
sqlx = { workspace = true }
activitypub = { workspace = true }
k-ap = { version = "0.4.1", registry = "gitea" }
adapter-common = { workspace = true }
k-ap = { version = "0.5.0", registry = "gitea" }
domain = { workspace = true }
anyhow = { workspace = true }
serde_json = { workspace = true }

View File

@@ -3,7 +3,8 @@ use async_trait::async_trait;
use chrono::Utc;
use k_ap::ActivityRepository;
use super::{SqliteFederationRepository, datetime_to_str};
use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl ActivityRepository for SqliteFederationRepository {

View File

@@ -1,32 +1,28 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{ActorRepository, RemoteActor};
use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache};
use sqlx::Row;
use super::{SqliteFederationRepository, datetime_to_str, remote_actor_from_row};
use super::{SqliteFederationRepository, remote_actor_from_row};
use adapter_common::datetime_to_str;
#[async_trait]
impl ActorRepository for SqliteFederationRepository {
async fn get_local_actor_keypair(
&self,
user_id: uuid::Uuid,
) -> Result<Option<(String, String)>> {
impl KeypairRepository for SqliteFederationRepository {
async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result<Option<Keypair>> {
let uid = user_id.to_string();
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.get("public_key"), r.get("private_key"))))
Ok(row.map(|r| Keypair {
public_key: r.get("public_key"),
private_key: r.get("private_key"),
}))
}
async fn save_local_actor_keypair(
&self,
user_id: uuid::Uuid,
public_key: String,
private_key: String,
) -> Result<()> {
async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> {
let uid = user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
@@ -38,14 +34,17 @@ impl ActorRepository for SqliteFederationRepository {
private_key = excluded.private_key",
)
.bind(&uid)
.bind(&public_key)
.bind(&private_key)
.bind(&keypair.public_key)
.bind(&keypair.private_key)
.bind(&created_at)
.execute(&self.pool)
.await?;
Ok(())
}
}
#[async_trait]
impl RemoteActorCache for SqliteFederationRepository {
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);
@@ -83,7 +82,10 @@ impl ActorRepository for SqliteFederationRepository {
).bind(actor_url).fetch_optional(&self.pool).await?;
Ok(row.as_ref().map(|r| remote_actor_from_row(r, "url")))
}
}
#[async_trait]
impl AnnounceRepository for SqliteFederationRepository {
async fn add_announce(
&self,
activity_id: &str,

View File

@@ -2,17 +2,16 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, Goal, GoalType, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry,
WatchlistWithMovie,
},
ports::LocalApContentQuery,
value_objects::{
Comment, ExternalMetadataId, GoalId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, WatchlistEntryId,
},
};
use sqlx::{Row, SqlitePool};
use uuid::Uuid;
use sqlx::SqlitePool;
pub struct SqliteApContentQuery {
pool: SqlitePool,
@@ -22,24 +21,11 @@ impl SqliteApContentQuery {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
// ── Local row types ──────────────────────────────────────────────────────────
fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
fn parse_datetime(s: &str) -> Result<chrono::NaiveDateTime, DomainError> {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}
use adapter_common::{parse_datetime, parse_uuid};
#[derive(sqlx::FromRow)]
struct MovieRow {
@@ -82,6 +68,7 @@ struct ReviewRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl ReviewRow {
@@ -97,6 +84,7 @@ impl ReviewRow {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?;
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
@@ -106,6 +94,7 @@ impl ReviewRow {
watched_at,
created_at,
source,
watch_medium,
}))
}
}
@@ -126,6 +115,7 @@ struct DiaryRow {
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
}
impl DiaryRow {
@@ -148,6 +138,7 @@ impl DiaryRow {
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
watch_medium: self.watch_medium,
}
.into_domain()?;
Ok(DiaryEntry::new(movie, review))
@@ -189,100 +180,10 @@ impl WatchlistRow {
}
}
fn row_to_goal(r: &sqlx::sqlite::SqliteRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(
Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid goal UUID: {e}")))?,
);
let user_id = UserId::from_uuid(
Uuid::parse_str(&user_id_str)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid user UUID: {e}")))?,
);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}
async fn count_reviews_in_year(
pool: &SqlitePool,
user_id: &UserId,
year: u16,
) -> Result<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = ? AND watched_at >= ? AND watched_at < ? \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as u32)
}
// ── LocalApContentQuery impl ─────────────────────────────────────────────────
#[async_trait]
impl LocalApContentQuery for SqliteApContentQuery {
async fn get_local_reviews_for_user(
&self,
user_id: &UserId,
) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
ORDER BY r.created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_local_watchlist_for_user(
&self,
user_id: &UserId,
@@ -300,7 +201,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(WatchlistRow::into_domain).collect()
}
@@ -311,7 +212,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let mid = movie_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ? AND r.remote_actor_url IS NULL
@@ -320,63 +221,10 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(&mid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_review_by_id(&self, review_id: &ReviewId) -> Result<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
FROM reviews WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(ReviewRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_external_metadata_id(
&self,
external_id: &str,
) -> Result<Option<Movie>, DomainError> {
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(external_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(count as u64)
}
async fn get_local_reviews_page(
&self,
user_id: &UserId,
@@ -390,7 +238,7 @@ impl LocalApContentQuery for SqliteApContentQuery {
let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string();
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL AND r.watched_at < ?
@@ -402,11 +250,11 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
} else {
sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL
@@ -417,47 +265,8 @@ impl LocalApContentQuery for SqliteApContentQuery {
.bind(limit_i64)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
};
rows.into_iter().map(DiaryRow::into_domain).collect()
}
async fn get_goal_with_progress(
&self,
user_id: &UserId,
year: u16,
) -> Result<Option<(Goal, u32)>, DomainError> {
let uid = user_id.value().to_string();
let y = year as i64;
let row = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? AND year = ?",
)
.bind(&uid)
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
let Some(r) = row else { return Ok(None) };
let goal = row_to_goal(&r)?;
let count = count_reviews_in_year(&self.pool, user_id, year).await?;
Ok(Some((goal, count)))
}
async fn list_goals_for_user(&self, user_id: &UserId) -> Result<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, created_at \
FROM goals WHERE user_id = ? ORDER BY created_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.iter().map(row_to_goal).collect()
}
}

View File

@@ -1,13 +1,14 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{BlockedDomain, BlocklistRepository};
use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist};
use sqlx::Row;
use super::{SqliteFederationRepository, datetime_to_str};
use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl BlocklistRepository for SqliteFederationRepository {
impl DomainBlocklist for SqliteFederationRepository {
async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> {
let now = Utc::now().naive_utc();
let ts = datetime_to_str(&now);
@@ -55,7 +56,10 @@ impl BlocklistRepository for SqliteFederationRepository {
.await?;
Ok(count > 0)
}
}
#[async_trait]
impl ActorBlocklist for SqliteFederationRepository {
async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
let ts = datetime_to_str(&Utc::now().naive_utc());

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::SqliteFederationRepository;
#[async_trait]
impl FederatedProfileQuery for SqliteFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = ? AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -1,18 +1,14 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{
ActorRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
};
use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor};
use sqlx::Row;
use super::{
SqliteFederationRepository, datetime_to_str, remote_actor_from_row, status_to_str,
str_to_status,
};
use crate::{SqliteFederationRepository, remote_actor_from_row, status_to_str, str_to_status};
use adapter_common::datetime_to_str;
#[async_trait]
impl FollowRepository for SqliteFederationRepository {
impl FollowerWriter for SqliteFederationRepository {
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
@@ -71,6 +67,31 @@ impl FollowRepository for SqliteFederationRepository {
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query(
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowerReader for SqliteFederationRepository {
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
@@ -140,28 +161,6 @@ impl FollowRepository for SqliteFederationRepository {
Ok(count as usize)
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowerStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = status_to_str(&status);
let result = sqlx::query(
"UPDATE ap_followers SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found");
}
Ok(())
}
async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
@@ -234,173 +233,4 @@ impl FollowRepository for SqliteFederationRepository {
.map(|row| remote_actor_from_row(row, "remote_actor_url"))
.collect())
}
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
ActorRepository::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES (?, ?, ?, ?)",
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query(
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
async fn get_following_outbox_url(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT a.outbox_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.remote_actor_url = ?",
)
.bind(&uid)
.bind(remote_actor_url)
.fetch_optional(&self.pool)
.await?;
Ok(row.flatten())
}
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following
WHERE remote_actor_url = ?1
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = ?1
WHERE remote_actor_url = ?2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,124 @@
use anyhow::Result;
use async_trait::async_trait;
use chrono::Utc;
use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache};
use crate::{SqliteFederationRepository, remote_actor_from_row};
use adapter_common::datetime_to_str;
#[async_trait]
impl FollowingWriter for SqliteFederationRepository {
async fn add_following(
&self,
local_user_id: uuid::Uuid,
actor: RemoteActor,
follow_activity_id: &str,
) -> Result<()> {
let uid = local_user_id.to_string();
let now = Utc::now().naive_utc();
let created_at = datetime_to_str(&now);
RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?;
sqlx::query(
"INSERT OR IGNORE INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at)
VALUES (?, ?, ?, ?)",
).bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?;
Ok(())
}
async fn get_follow_activity_id(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
) -> Result<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<Option<String>> = sqlx::query_scalar(
"SELECT follow_activity_id FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?",
).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row.flatten())
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ? AND remote_actor_url = ?")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query(
"UPDATE ap_following SET status = ? WHERE local_user_id = ? AND remote_actor_url = ?",
)
.bind(status_str)
.bind(&uid)
.bind(remote_actor_url)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowingReader for SqliteFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'",
).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url,
a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC LIMIT ? OFFSET ?",
).bind(&uid).bind(limit as i64).bind(offset as i64).fetch_all(&self.pool).await?;
Ok(rows
.iter()
.map(|row| remote_actor_from_row(row, "url"))
.collect())
}
}

View File

@@ -0,0 +1,47 @@
use anyhow::Result;
use async_trait::async_trait;
use k_ap::FollowMigration;
use crate::SqliteFederationRepository;
#[async_trait]
impl FollowMigration for SqliteFederationRepository {
async fn migrate_follower_actor(
&self,
old_actor_url: &str,
new_actor_url: &str,
) -> Result<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following
WHERE remote_actor_url = ?1
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?2
)",
)
.bind(old_actor_url)
.bind(new_actor_url)
.fetch_all(&self.pool)
.await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query(
"UPDATE ap_following SET remote_actor_url = ?1
WHERE remote_actor_url = ?2
AND local_user_id NOT IN (
SELECT local_user_id FROM ap_following WHERE remote_actor_url = ?1
)",
)
.bind(new_actor_url)
.bind(old_actor_url)
.execute(&self.pool)
.await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

@@ -0,0 +1,3 @@
mod followers;
mod following;
mod migration;

View File

@@ -0,0 +1,303 @@
use async_trait::async_trait;
use chrono::Utc;
use domain::{
errors::DomainError,
value_objects::{FollowStatus, SocialActor, SocialIdentity},
};
use sqlx::Row;
use crate::SqliteFederationRepository;
use adapter_common::datetime_to_str;
fn follow_status_to_str(status: &FollowStatus) -> &'static str {
match status {
FollowStatus::Pending => "pending",
FollowStatus::Accepted => "accepted",
FollowStatus::Rejected => "rejected",
}
}
fn infra_err(e: impl std::fmt::Display) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl domain::ports::FollowCommand for SqliteFederationRepository {
async fn add_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status)
VALUES (?1, ?2, '', ?3, ?4)
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
)
.bind(&uid)
.bind(target_actor_url)
.bind(&now)
.bind(status_str)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follow_status(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_following SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
)
.bind(status_str)
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follow(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<(), DomainError> {
let uid = follower_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = ?1 AND remote_actor_url = ?2")
.bind(&uid)
.bind(target_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn add_follower(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
let now = datetime_to_str(&Utc::now().naive_utc());
sqlx::query(
"INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id)
VALUES (?1, ?2, ?3, ?4, '')
ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = excluded.status",
)
.bind(&uid)
.bind(follower_actor_url)
.bind(status_str)
.bind(&now)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
status: FollowStatus,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
let status_str = follow_status_to_str(&status);
sqlx::query(
"UPDATE ap_followers SET status = ?1 WHERE local_user_id = ?2 AND remote_actor_url = ?3",
)
.bind(status_str)
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
async fn remove_follower_record(
&self,
local_user_id: uuid::Uuid,
follower_actor_url: &str,
) -> Result<(), DomainError> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_followers WHERE local_user_id = ?1 AND remote_actor_url = ?2")
.bind(&uid)
.bind(follower_actor_url)
.execute(&self.pool)
.await
.map_err(infra_err)?;
Ok(())
}
}
fn social_actor_from_row(row: &sqlx::sqlite::SqliteRow, base_url: &str) -> SocialActor {
let actor_url: String = row.get("remote_actor_url");
let identity = SocialIdentity::from_actor_url(&actor_url, base_url);
let (handle, display_name, avatar_url) = match &identity {
SocialIdentity::Local(_) => {
let username: Option<String> = row.try_get("local_username").ok().flatten();
let display: Option<String> = row.try_get("local_display").ok().flatten();
let avatar: Option<String> = row
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| format!("{}/images/{}", base_url, p));
let handle = username
.as_deref()
.map(|u| SocialIdentity::format_local_handle(u, base_url))
.unwrap_or_else(|| actor_url.clone());
(handle, display, avatar)
}
SocialIdentity::Remote { .. } => {
let handle: String = row
.try_get("remote_handle")
.ok()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
(handle, display, avatar)
}
};
SocialActor {
identity,
handle,
display_name,
avatar_url,
}
}
#[async_trait]
impl domain::ports::FollowQuery for SqliteFederationRepository {
async fn get_following(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(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))
.collect())
}
async fn get_followers(
&self,
user_id: uuid::Uuid,
base_url: &str,
) -> Result<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'accepted'",
)
.bind(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))
.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(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = ?1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ?2 AND f.status = 'pending'",
)
.bind(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))
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn is_following(
&self,
follower_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<bool, DomainError> {
let uid = follower_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'",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count > 0)
}
}

View File

@@ -1,24 +1,22 @@
mod activity;
mod actor;
pub mod ap_content;
mod blocklist;
mod federated_profile;
mod follow;
pub mod remote_goals;
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 chrono::NaiveDateTime;
use k_ap::{FollowerStatus, RemoteActor};
use sqlx::SqlitePool;
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str {
match status {
FollowerStatus::Pending => "pending",
@@ -86,22 +84,26 @@ impl SqliteFederationRepository {
}
}
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
(
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
std::sync::Arc::clone(&fed) as _,
fed as _,
)
pub fn create_federated_profile_query(
pool: SqlitePool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(SqliteFederationRepository::new(pool))
}
#[cfg(test)]
#[path = "tests/outbox_url.rs"]
mod outbox_url_tests;
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
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 _,
}
}
#[cfg(test)]
#[path = "tests/actor_block_tests.rs"]

View File

@@ -11,11 +11,6 @@ impl SqliteRemoteGoalRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -36,7 +31,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(&received)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -53,7 +48,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(ap_id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -64,7 +59,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -74,7 +69,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -87,7 +82,7 @@ impl RemoteGoalRepository for SqliteRemoteGoalRepository {
.bind(actor_url)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter()
.map(|r| {

View File

@@ -1,9 +1,10 @@
use activitypub::RemoteReviewRepository;
use activitypub::{RemoteReviewRepository, RemoteReviewUpdate};
use anyhow::{Result, anyhow};
use async_trait::async_trait;
use domain::models::{Review, ReviewSource};
use super::{SqliteFederationRepository, datetime_to_str};
use super::SqliteFederationRepository;
use adapter_common::datetime_to_str;
#[async_trait]
impl RemoteReviewRepository for SqliteFederationRepository {
@@ -71,35 +72,28 @@ impl RemoteReviewRepository for SqliteFederationRepository {
Ok(())
}
async fn update_remote_review(
&self,
ap_id: &str,
actor_url: &str,
rating: u8,
comment: Option<&str>,
watched_at: chrono::NaiveDateTime,
poster_url: Option<&str>,
) -> Result<()> {
let watched_at_str = datetime_to_str(&watched_at);
async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> {
let watched_at_str = datetime_to_str(&u.watched_at);
sqlx::query(
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?
"UPDATE reviews SET rating = ?, comment = ?, watched_at = ?, watch_medium = ?
WHERE ap_id = ? AND remote_actor_url = ?",
)
.bind(rating as i64)
.bind(comment)
.bind(u.rating as i64)
.bind(u.comment)
.bind(&watched_at_str)
.bind(ap_id)
.bind(actor_url)
.bind(u.watch_medium)
.bind(u.ap_id)
.bind(u.actor_url)
.execute(&self.pool)
.await?;
if let Some(url) = poster_url {
if let Some(url) = u.poster_url {
sqlx::query(
"UPDATE movies SET poster_path = ?
WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = ? AND remote_actor_url = ?)",
)
.bind(url)
.bind(ap_id)
.bind(actor_url)
.bind(u.ap_id)
.bind(u.actor_url)
.execute(&self.pool)
.await?;
}

View File

@@ -1,25 +1,10 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{PendingFollowerInfo, RemoteActorInfo},
ports::SocialQueryPort,
};
use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery};
use super::SqliteFederationRepository;
#[async_trait]
impl SocialQueryPort for SqliteFederationRepository {
async fn get_accepted_following_urls(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<String>, DomainError> {
let user_id_str = user_id.to_string();
sqlx::query_scalar::<_, String>(
"SELECT remote_actor_url FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
).bind(&user_id_str).fetch_all(&self.pool).await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
impl FederationAdminQuery for SqliteFederationRepository {
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
@@ -39,56 +24,4 @@ impl SocialQueryPort for SqliteFederationRepository {
})
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn count_accepted_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(count as usize)
}
async fn get_pending_followers(
&self,
user_id: uuid::Uuid,
) -> Result<Vec<PendingFollowerInfo>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT ar.url, ar.handle, ar.display_name, ar.avatar_url
FROM ap_followers f
JOIN ap_remote_actors ar ON ar.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'pending'",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(
|(url, handle, display_name, avatar_url)| PendingFollowerInfo {
url,
handle,
display_name,
avatar_url,
},
)
.collect())
}
}

View File

@@ -1,5 +1,5 @@
use super::*;
use k_ap::BlocklistRepository;
use k_ap::ActorBlocklist;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {

View File

@@ -1,5 +1,5 @@
use super::*;
use k_ap::BlocklistRepository;
use k_ap::DomainBlocklist;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {

View File

@@ -1,7 +1,7 @@
use super::*;
use chrono::Utc;
use domain::ports::SocialQueryPort;
use k_ap::ActorRepository;
use domain::ports::FederationAdminQuery;
use k_ap::AnnounceRepository;
use sqlx::SqlitePool;
async fn test_pool() -> SqlitePool {
@@ -79,29 +79,6 @@ async fn setup_db(pool: &SqlitePool) {
.unwrap();
}
#[tokio::test]
async fn test_get_accepted_following_urls_returns_only_accepted() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup_db(&pool).await;
let repo = SqliteFederationRepository::new(pool.clone());
let user_id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, status)
VALUES (?, 'https://other.social/users/alice', 'act1', 'accepted'),
(?, 'https://other.social/users/bob', 'act2', 'pending')",
)
.bind(user_id.to_string())
.bind(user_id.to_string())
.execute(&pool)
.await
.unwrap();
let urls = repo.get_accepted_following_urls(user_id).await.unwrap();
assert_eq!(urls.len(), 1);
assert_eq!(urls[0], "https://other.social/users/alice");
}
#[tokio::test]
async fn test_list_all_followed_remote_actors_deduplicates() {
let pool = SqlitePool::connect(":memory:").await.unwrap();

View File

@@ -1,76 +0,0 @@
use super::*;
use k_ap::{FollowRepository, FollowingStatus, RemoteActor};
async fn setup_pool() -> SqlitePool {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE 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,
outbox_url TEXT, bio TEXT, banner_url TEXT, followers_url TEXT,
following_url TEXT, also_known_as TEXT, fetched_at TEXT NOT NULL
);
CREATE TABLE ap_following (
local_user_id TEXT NOT NULL, remote_actor_url TEXT NOT NULL,
follow_activity_id TEXT, created_at TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
PRIMARY KEY (local_user_id, remote_actor_url)
);",
)
.execute(&pool)
.await
.unwrap();
pool
}
#[tokio::test]
async fn get_following_outbox_url_returns_stored_url() {
let pool = setup_pool().await;
let repo = SqliteFederationRepository::new(pool);
let local_user = uuid::Uuid::new_v4();
let actor = RemoteActor {
url: "https://remote.example/users/alice".to_string(),
handle: "alice@remote.example".to_string(),
inbox_url: "https://remote.example/users/alice/inbox".to_string(),
shared_inbox_url: None,
display_name: None,
avatar_url: None,
outbox_url: Some("https://remote.example/users/alice/outbox".to_string()),
bio: None,
banner_url: None,
followers_url: None,
following_url: None,
also_known_as: vec![],
fetched_at: None,
};
repo.add_following(local_user, actor, "https://local/activities/1")
.await
.unwrap();
repo.update_following_status(
local_user,
"https://remote.example/users/alice",
FollowingStatus::Accepted,
)
.await
.unwrap();
let result = repo
.get_following_outbox_url(local_user, "https://remote.example/users/alice")
.await
.unwrap();
assert_eq!(
result,
Some("https://remote.example/users/alice/outbox".to_string())
);
}
#[tokio::test]
async fn get_following_outbox_url_returns_none_when_not_following() {
let pool = setup_pool().await;
let repo = SqliteFederationRepository::new(pool);
let result = repo
.get_following_outbox_url(uuid::Uuid::new_v4(), "https://remote.example/users/alice")
.await
.unwrap();
assert_eq!(result, None);
}

View File

@@ -4,10 +4,11 @@ version = "0.1.0"
edition = "2021"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
domain = { workspace = true }
adapter-common = { workspace = true }
async-trait = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -31,10 +31,6 @@ pub fn create_search_adapter(pool: SqlitePool) -> (Arc<dyn SearchCommand>, Arc<d
)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl SearchCommand for SqliteSearchAdapter {
async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> {
@@ -86,7 +82,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&movie_id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT INTO movies_fts(movie_id, title, director, overview, genres, keywords, cast_names, crew_names, release_year, language)
@@ -104,7 +100,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&language)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -118,7 +114,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(&person_id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT INTO people_fts(person_id, name, known_for_department) VALUES (?, ?, ?)",
@@ -128,7 +124,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(person.known_for_department())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -144,7 +140,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
EntityType::Person => {
sqlx::query(
@@ -153,7 +149,7 @@ impl SearchCommand for SqliteSearchAdapter {
.bind(id)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
}
Ok(())
@@ -205,7 +201,7 @@ impl SqliteSearchAdapter {
.bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
} else {
let count: i64 = sqlx::query_scalar(
@@ -221,7 +217,7 @@ impl SqliteSearchAdapter {
.bind(query.filters.year.map(|y| y as i64))
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
};
@@ -249,7 +245,7 @@ impl SqliteSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
} else {
sqlx::query_as::<_, Row>(
"SELECT m.id, m.title, m.release_year, m.director, m.poster_path,
@@ -270,7 +266,7 @@ impl SqliteSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
};
let items = rows
.into_iter()
@@ -321,7 +317,7 @@ impl SqliteSearchAdapter {
.bind(&fts_query)
.fetch_one(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count as u64
};
@@ -346,7 +342,7 @@ impl SqliteSearchAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let mut items = Vec::with_capacity(rows.len());
for row in rows {
@@ -355,7 +351,7 @@ impl SqliteSearchAdapter {
.bind(&row.person_id)
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.flatten();
let known_for_titles = if let Some(tid) = tmdb_id {

View File

@@ -11,6 +11,7 @@ sqlx = { version = "0.8.6", features = [
"macros",
] }
adapter-common = { workspace = true }
domain = { workspace = true }
sqlite-federation = { workspace = true }
serde = { workspace = true, features = ["derive"] }

View File

@@ -0,0 +1 @@
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;

View File

@@ -2,10 +2,10 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy,
collections::{PageParams, Paginated},
},
ports::DiaryRepository,
ports::DiaryQuery,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
@@ -22,42 +22,37 @@ impl SqliteDiaryRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result<i64, DomainError> {
match movie_id {
None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err),
.map_err(adapter_common::map_sqlx_error),
Some(id) => {
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
.bind(id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}
}
async fn fetch_all_diary_rows(
&self,
sort: &SortDirection,
sort: &ReviewSortBy,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
@@ -69,25 +64,25 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
sort: &ReviewSortBy,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ?
@@ -101,62 +96,74 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn count_user_diary_entries(
&self,
user_id: &str,
search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND m.title LIKE '%' || ? || '%'"
.to_string()
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?"
.to_string()
};
WHERE r.user_id = ?{remote_clause}{search_clause}"
);
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.fetch_one(&self.pool).await.map_err(Self::map_err)
q.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_diary_rows(
&self,
user_id: &str,
sort: &SortDirection,
sort: &ReviewSortBy,
search: Option<&str>,
include_remote: bool,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'"
} else {
""
};
let order_clause = match sort {
SortDirection::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
SortDirection::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC",
ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC",
ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC",
ReviewSortBy::Ascending => "r.watched_at ASC",
ReviewSortBy::Descending => "r.watched_at DESC",
};
let sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL{}
ORDER BY {}
WHERE r.user_id = ?{remote_clause}{search_clause}
ORDER BY {order_clause}
LIMIT ? OFFSET ?",
search_clause, order_clause
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search {
@@ -166,12 +173,12 @@ impl SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}
#[async_trait]
impl DiaryRepository for SqliteDiaryRepository {
impl DiaryQuery for SqliteDiaryRepository {
async fn query_diary(
&self,
filter: &DiaryFilter,
@@ -194,9 +201,17 @@ impl DiaryRepository for SqliteDiaryRepository {
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)?
}
(Some(_), Some(_)) => {
@@ -290,8 +305,9 @@ impl DiaryRepository for SqliteDiaryRepository {
let select_sql = format!(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
r.watched_at, r.created_at, r.remote_actor_url,
COALESCE(u.email, a.handle, r.remote_actor_url) AS user_email
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
ELSE COALESCE(u.email, r.user_id) END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
@@ -321,7 +337,10 @@ impl DiaryRepository for SqliteDiaryRepository {
}
let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql));
let total = count_q.fetch_one(&self.pool).await.map_err(Self::map_err)?;
let total = count_q
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
@@ -329,7 +348,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -354,18 +373,18 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))?
.into_domain()?;
let viewings = sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url
"SELECT id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
@@ -377,7 +396,7 @@ impl DiaryRepository for SqliteDiaryRepository {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -386,7 +405,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
@@ -400,7 +419,7 @@ impl DiaryRepository for SqliteDiaryRepository {
Box::pin(async_stream::stream! {
let mut rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
@@ -411,7 +430,7 @@ impl DiaryRepository for SqliteDiaryRepository {
while let Some(row) = futures::StreamExt::next(&mut rows).await {
yield match row {
Ok(r) => r.into_domain(),
Err(e) => Err(Self::map_err(e)),
Err(e) => Err(adapter_common::map_sqlx_error(e)),
};
}
})
@@ -434,7 +453,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
.map(MovieStatsRow::into_domain)
}
@@ -452,18 +471,18 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let rows = sqlx::query_as::<_, FeedRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
r.watched_at, r.created_at, r.remote_actor_url,
CASE WHEN r.remote_actor_url IS NOT NULL THEN r.remote_actor_url
WHEN u.email IS NOT NULL THEN u.email
ELSE r.user_id END AS user_email
r.watched_at, r.created_at, r.remote_actor_url, r.watch_medium,
CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url)
ELSE COALESCE(u.email, r.user_id) END AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url
WHERE r.movie_id = ?
ORDER BY r.watched_at DESC
LIMIT ? OFFSET ?",
@@ -473,7 +492,7 @@ impl DiaryRepository for SqliteDiaryRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let items = rows
.into_iter()
@@ -493,7 +512,7 @@ impl DiaryRepository for SqliteDiaryRepository {
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(count as u64)
}
}

View File

@@ -2,7 +2,7 @@ use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{Goal, GoalType},
ports::GoalRepository,
ports::{GoalCommand, GoalQuery},
value_objects::{GoalId, UserId},
};
use sqlx::{Row, SqlitePool};
@@ -15,15 +15,10 @@ impl SqliteGoalRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl GoalRepository for SqliteGoalRepository {
impl GoalCommand for SqliteGoalRepository {
async fn save(&self, goal: &Goal) -> Result<(), DomainError> {
let id = goal.id().value().to_string();
let user_id = goal.user_id().value().to_string();
@@ -44,7 +39,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -58,7 +53,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
@@ -75,14 +70,17 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&uid)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
if result.rows_affected() == 0 {
return Err(DomainError::NotFound("Goal not found".into()));
}
Ok(())
}
}
#[async_trait]
impl GoalQuery for SqliteGoalRepository {
async fn find_by_user_and_year(
&self,
user_id: &UserId,
@@ -99,7 +97,7 @@ impl GoalRepository for SqliteGoalRepository {
.bind(y)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| row_to_goal(&r)).transpose()
}
@@ -114,14 +112,10 @@ impl GoalRepository for SqliteGoalRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter().map(row_to_goal).collect()
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
count_reviews_in_year(&self.pool, user_id, year).await
}
}
pub(crate) async fn count_reviews_in_year(

View File

@@ -97,11 +97,6 @@ impl SqliteImportProfileRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
@@ -130,7 +125,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn list_for_user(&self, user_id: &UserId) -> Result<Vec<ImportProfile>, DomainError> {
@@ -141,7 +136,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
rows.iter()
.map(|r| {
@@ -183,7 +178,7 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| {
use sqlx::Row;
@@ -215,6 +210,6 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}

View File

@@ -5,7 +5,6 @@ use domain::{
models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
},
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId},
@@ -203,11 +202,6 @@ impl SqliteImportSessionRepository {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("DB error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
fn parse_dt(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.or_else(|_| NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S"))
@@ -276,7 +270,7 @@ impl SqliteImportSessionRepository {
})
.transpose()?;
Ok(ImportSession::from_persistence(PersistedImportSession {
Ok(ImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -291,7 +285,7 @@ impl SqliteImportSessionRepository {
row_results,
created_at: Self::parse_dt(created_at)?,
expires_at: Self::parse_dt(expires_at)?,
}))
})
}
}
@@ -317,7 +311,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn get(
@@ -335,7 +329,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.bind(&uid_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
row.map(|r| {
use sqlx::Row;
@@ -362,7 +356,7 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> {
@@ -372,14 +366,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
async fn delete_expired(&self) -> Result<u64, DomainError> {
let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')")
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
@@ -392,6 +386,6 @@ impl ImportSessionRepository for SqliteImportSessionRepository {
.execute(&self.pool)
.await
.map(|_| ())
.map_err(Self::map_err)
.map_err(adapter_common::map_sqlx_error)
}
}

View File

@@ -45,39 +45,16 @@ pub fn create_profile_fields_repo(
std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool))
}
pub(crate) fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {
return ym.to_string();
}
let year = parts[0].get(2..).unwrap_or(parts[0]);
let month = match parts[1] {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => parts[1],
};
format!("{} '{}", month, year)
}
pub async fn migrate(pool: &SqlitePool) -> Result<(), domain::errors::DomainError> {
migrations::run(pool).await
}
pub struct SqliteWireOutput {
pub pool: SqlitePool,
pub movie: std::sync::Arc<dyn domain::ports::MovieRepository>,
pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
@@ -87,7 +64,8 @@ pub struct SqliteWireOutput {
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
@@ -119,9 +97,12 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
pool.clone(),
));
let movie_repo = std::sync::Arc::new(SqliteMovieRepository::new(pool.clone()));
Ok(SqliteWireOutput {
pool: pool.clone(),
movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
movie_command: movie_repo.clone() as _,
movie_query: movie_repo as _,
review: std::sync::Arc::new(SqliteReviewRepository::new(pool.clone())) as _,
diary: std::sync::Arc::new(SqliteDiaryRepository::new(pool.clone())) as _,
stats: std::sync::Arc::new(SqliteStatsRepository::new(pool.clone())) as _,
@@ -133,7 +114,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
ap_content: std::sync::Arc::new(SqliteApContentQuery::new(pool.clone())) as _,
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
goal_command: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
goal_query: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
federation_settings: user_settings_repo as _,
remote_goal: std::sync::Arc::new(sqlite_federation::SqliteRemoteGoalRepository::new(

View File

@@ -1,16 +1,11 @@
use chrono::NaiveDateTime;
use adapter_common::{
movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain,
user_summary_to_domain, watchlist_entry_to_domain, watchlist_with_movie_to_domain,
};
use domain::{
errors::DomainError,
models::{
DiaryEntry, FeedEntry, Movie, MovieSummary, PersistedReview, Review, ReviewSource,
UserSummary, WatchlistEntry, WatchlistWithMovie,
},
value_objects::{
Comment, Email, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear,
ReviewId, UserId, Username, WatchlistEntryId,
},
models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review, WatchlistWithMovie},
};
use uuid::Uuid;
#[derive(sqlx::FromRow)]
pub(crate) struct MovieRow {
@@ -24,22 +19,14 @@ pub(crate) struct MovieRow {
impl MovieRow {
pub fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
let external_metadata_id = self
.external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(self.title)?;
let release_year = ReleaseYear::new(self.release_year as u16)?;
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
poster_path,
))
self.poster_path,
)
}
}
@@ -60,27 +47,26 @@ pub(crate) struct MovieSummaryRow {
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
let genres = self
.genres
.map(|g| g.split(',').map(str::to_string).collect())
.unwrap_or_default();
Ok(MovieSummary {
Ok(movie_summary_to_domain(
movie,
genres,
runtime_minutes: self.runtime_minutes.map(|v| v as u32),
original_language: self.original_language,
overview: self.overview,
collection_name: self.collection_name,
})
self.runtime_minutes,
self.original_language,
self.overview,
self.collection_name,
))
}
}
@@ -94,31 +80,22 @@ pub(crate) struct ReviewRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl ReviewRow {
pub fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
let rating = Rating::new(self.rating as u8)?;
let comment = self.comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&self.watched_at)?;
let created_at = parse_datetime(&self.created_at)?;
let source = match self.remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
Ok(Review::from_persistence(PersistedReview {
id,
movie_id,
user_id,
rating,
comment,
watched_at,
created_at,
source,
}))
review_row_to_domain(
self.id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)
}
}
@@ -139,32 +116,30 @@ pub(crate) struct DiaryRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
}
impl DiaryRow {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
let review = ReviewRow {
id: self.review_id,
movie_id: self.movie_id,
user_id: self.user_id,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.into_domain()?;
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
let review = review_row_to_domain(
self.review_id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)?;
Ok(DiaryEntry::new(movie, review))
}
}
@@ -183,18 +158,18 @@ pub(crate) struct MovieStatsRow {
impl MovieStatsRow {
pub fn into_domain(self) -> domain::models::MovieStats {
domain::models::MovieStats {
total_count: self.total_count as u64,
avg_rating: self.avg_rating,
federated_count: self.federated_count as u64,
rating_histogram: [
self.rating_1 as u64,
self.rating_2 as u64,
self.rating_3 as u64,
self.rating_4 as u64,
self.rating_5 as u64,
movie_stats_to_domain(
self.total_count,
self.avg_rating,
self.federated_count,
[
self.rating_1,
self.rating_2,
self.rating_3,
self.rating_4,
self.rating_5,
],
}
)
}
}
@@ -215,28 +190,32 @@ pub(crate) struct FeedRow {
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub user_email: String,
}
impl FeedRow {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
let diary = DiaryRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
review_id: self.review_id,
movie_id: self.movie_id,
user_id: self.user_id,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
}
.into_domain()?;
let movie = movie_row_to_domain(
self.id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
let review = review_row_to_domain(
self.review_id,
self.movie_id,
self.user_id,
self.rating,
self.comment,
self.watched_at,
self.created_at,
self.remote_actor_url,
self.watch_medium,
)?;
let diary = DiaryEntry::new(movie, review);
Ok(FeedEntry::new(diary, self.user_email))
}
}
@@ -253,16 +232,16 @@ pub(crate) struct UserSummaryRow {
}
impl UserSummaryRow {
pub fn into_domain(self) -> Result<UserSummary, DomainError> {
Ok(UserSummary::new(
UserId::from_uuid(parse_uuid(&self.id)?),
Email::new(self.email)?,
Username::new(self.username)?,
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
user_summary_to_domain(
self.id,
self.email,
self.username,
self.display_name,
self.total_movies,
self.avg_rating,
self.avatar_path,
))
)
}
}
@@ -285,6 +264,24 @@ pub(crate) struct MonthlyRatingRow {
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct GenreCountRow {
pub genre: String,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct RatingDistRow {
pub rating: i64,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct WatchMediumCountRow {
pub watch_medium: String,
pub count: i64,
}
#[derive(sqlx::FromRow)]
pub(crate) struct WatchlistRow {
pub id: String,
@@ -301,35 +298,15 @@ pub(crate) struct WatchlistRow {
impl WatchlistRow {
pub fn into_domain(self) -> Result<WatchlistWithMovie, DomainError> {
let entry = WatchlistEntry {
id: WatchlistEntryId::from_uuid(parse_uuid(&self.id)?),
user_id: UserId::from_uuid(parse_uuid(&self.user_id)?),
movie_id: MovieId::from_uuid(parse_uuid(&self.movie_id)?),
added_at: parse_datetime(&self.added_at)?,
};
let movie = MovieRow {
id: self.m_id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
Ok(WatchlistWithMovie { entry, movie })
let entry = watchlist_entry_to_domain(self.id, self.user_id, self.movie_id, self.added_at)?;
let movie = movie_row_to_domain(
self.m_id,
self.external_metadata_id,
self.title,
self.release_year,
self.director,
self.poster_path,
)?;
Ok(watchlist_with_movie_to_domain(entry, movie))
}
}
pub(crate) fn parse_uuid(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::InfrastructureError(format!("Invalid UUID '{}': {}", s, e)))
}
pub(crate) fn datetime_to_str(dt: &NaiveDateTime) -> String {
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub(crate) fn parse_datetime(s: &str) -> Result<NaiveDateTime, DomainError> {
NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S")
.map_err(|e| DomainError::InfrastructureError(format!("Invalid datetime '{}': {}", s, e)))
}

View File

@@ -5,7 +5,7 @@ use domain::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::MovieRepository,
ports::{MovieCommand, MovieQuery},
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::SqlitePool;
@@ -20,67 +20,10 @@ impl SqliteMovieRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
impl MovieRepository for SqliteMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let t = title.value();
let y = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = ? AND release_year = ?",
)
.bind(t)
.bind(y)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
impl MovieCommand for SqliteMovieRepository {
async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> {
let id = movie.id().value().to_string();
let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string());
@@ -107,7 +50,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
@@ -118,9 +61,64 @@ impl MovieRepository for SqliteMovieRepository {
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
#[async_trait]
impl MovieQuery for SqliteMovieRepository {
async fn get_movie_by_external_id(
&self,
external_metadata_id: &ExternalMetadataId,
) -> Result<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = ?",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let t = title.value();
let y = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = ? AND release_year = ?",
)
.bind(t)
.bind(y)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn existing_external_ids(
&self,
@@ -138,7 +136,10 @@ impl MovieRepository for SqliteMovieRepository {
for id in ids {
q = q.bind(id.value().to_string());
}
let rows = q.fetch_all(&self.pool).await.map_err(Self::map_err)?;
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().collect())
}
@@ -162,7 +163,10 @@ impl MovieRepository for SqliteMovieRepository {
for (t, y) in pairs {
q = q.bind(t.value().to_string()).bind(y.value() as i64);
}
let rows = q.fetch_all(&self.pool).await.map_err(Self::map_err)?;
let rows = q
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()
.map(|r| {
@@ -214,7 +218,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
@@ -232,7 +236,7 @@ impl MovieRepository for SqliteMovieRepository {
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.try_get(0)
.unwrap_or(0);
@@ -256,7 +260,7 @@ impl MovieRepository for SqliteMovieRepository {
)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()

View File

@@ -12,11 +12,6 @@ impl SqliteMovieDeduplicator {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -36,7 +31,11 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
let director = canonical.director().map(str::to_string);
let poster = canonical.poster_path().map(|p| p.value().to_string());
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
// 1. Upsert canonical movie record
sqlx::query(
@@ -47,7 +46,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
poster_path = COALESCE(excluded.poster_path, movies.poster_path)",
)
.bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster)
.execute(&mut *tx).await.map_err(Self::map_err)?;
.execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
// 2. Re-point simple FK tables
let reviews = sqlx::query("UPDATE reviews SET movie_id = ? WHERE movie_id = ?")
@@ -55,7 +54,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
let watchlist = sqlx::query("UPDATE watchlist_entries SET movie_id = ? WHERE movie_id = ?")
@@ -63,7 +62,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
let watch_events = sqlx::query("UPDATE watch_events SET movie_id = ? WHERE movie_id = ?")
@@ -71,7 +70,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
// 3. Re-point movie_profiles (PK — move only if canonical has none)
@@ -80,7 +79,7 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.rows_affected();
// 4. Re-point enrichment tables with composite PKs (INSERT OR IGNORE + DELETE)
@@ -93,12 +92,12 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name)
@@ -108,41 +107,41 @@ impl MovieDeduplicator for SqliteMovieDeduplicator {
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT OR IGNORE INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path)
SELECT ?, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = ?",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?;
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
"INSERT OR IGNORE INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path)
SELECT ?, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = ?",
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(Self::map_err)?;
).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
// 5. Delete the now-empty old movie record (remaining cascades are safe: all FKs cleared above)
sqlx::query("DELETE FROM movies WHERE id = ?")
.bind(&old)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
tx.commit().await.map_err(Self::map_err)?;
tx.commit().await.map_err(adapter_common::map_sqlx_error)?;
Ok(reviews + watchlist + watch_events + profiles)
}

View File

@@ -29,10 +29,6 @@ pub fn create_person_adapter(pool: SqlitePool) -> (Arc<dyn PersonCommand>, Arc<d
)
}
fn map_err(e: sqlx::Error) -> DomainError {
DomainError::InfrastructureError(e.to_string())
}
#[async_trait]
impl PersonCommand for SqlitePersonAdapter {
async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> {
@@ -56,7 +52,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(person.profile_path())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
Ok(())
}
@@ -89,7 +85,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(batch_size)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let has_more = rows.len() as u32 >= batch_size;
let mut count = 0u64;
@@ -109,7 +105,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(&row.profile_path)
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
count += 1;
}
Ok((count, has_more))
@@ -137,7 +133,7 @@ impl PersonCommand for SqlitePersonAdapter {
.bind(id.value().to_string())
.execute(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(())
}
}
@@ -151,7 +147,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -166,7 +162,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(row.map(PersonRow::into_person))
}
@@ -182,7 +178,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(id.value().to_string())
.fetch_optional(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.flatten();
let Some(tmdb_id) = tmdb_id else {
@@ -203,7 +199,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CastCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -224,7 +220,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(tmdb_id)
.fetch_all(&self.pool)
.await
.map_err(map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CrewCredit {
movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()),
@@ -247,7 +243,7 @@ impl PersonQuery for SqlitePersonAdapter {
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(PersonRow::into_person).collect())
}
@@ -265,7 +261,7 @@ impl PersonQuery for SqlitePersonAdapter {
)
.fetch_all(&self.pool)
.await
.map_err(map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()

View File

@@ -16,11 +16,6 @@ impl SqliteMovieProfileRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
fn map_err(e: sqlx::Error) -> DomainError {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
}
}
#[async_trait]
@@ -29,7 +24,11 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
let movie_id = p.movie_id.value().to_string();
let enriched_at = p.enriched_at.to_rfc3339();
let mut tx = self.pool.begin().await.map_err(Self::map_err)?;
let mut tx = self
.pool
.begin()
.await
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query(
r#"INSERT INTO movie_profiles
@@ -62,13 +61,13 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&enriched_at)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
sqlx::query("DELETE FROM movie_genres WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for g in &p.genres {
sqlx::query(
"INSERT OR IGNORE INTO movie_genres (movie_id, tmdb_id, name) VALUES (?,?,?)",
@@ -78,14 +77,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&g.name)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_keywords WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for k in &p.keywords {
sqlx::query(
"INSERT OR IGNORE INTO movie_keywords (movie_id, tmdb_id, name) VALUES (?,?,?)",
@@ -95,14 +94,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&k.name)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_cast WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for c in &p.cast {
sqlx::query(
"INSERT OR IGNORE INTO movie_cast \
@@ -117,14 +116,14 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&c.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
sqlx::query("DELETE FROM movie_crew WHERE movie_id = ?")
.bind(&movie_id)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
for cr in &p.crew {
sqlx::query(
"INSERT OR IGNORE INTO movie_crew \
@@ -139,10 +138,10 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&cr.profile_path)
.execute(&mut *tx)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
}
tx.commit().await.map_err(Self::map_err)
tx.commit().await.map_err(adapter_common::map_sqlx_error)
}
async fn get_by_movie_id(&self, id: &MovieId) -> Result<Option<MovieProfile>, DomainError> {
@@ -157,7 +156,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
let row = match row {
Some(r) => r,
@@ -175,7 +174,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| Genre {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -187,7 +186,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| Keyword {
tmdb_id: r.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u32,
@@ -202,7 +201,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CastMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -220,7 +219,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&movie_id)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(|r| CrewMember {
tmdb_person_id: r.try_get::<i64, _>("tmdb_person_id").unwrap_or(0) as u64,
@@ -273,7 +272,7 @@ impl MovieProfileRepository for SqliteMovieProfileRepository {
.bind(&threshold)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows
.into_iter()

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