Compare commits

..

39 Commits

Author SHA1 Message Date
7faf14fb2f docs: update project description to reflect current scope
All checks were successful
CI / Check / Test (push) Successful in 43m3s
2026-06-12 12:53:15 +02:00
ddb5966c9b docs: mention Insomnia collection in API section
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:51:24 +02:00
4ec231017e docs: badges, TOC, quick start, env var table, screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:46:31 +02:00
fab236688b screenshots
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 12:31:13 +02:00
4683a408d7 fix(spa): use import type for UpdateUserSettingsRequest
All checks were successful
CI / Check / Test (push) Successful in 38m33s
2026-06-12 02:29:50 +02:00
6d4c70553a fix: remove unused UserFederationSettingsQuery import in worker/db.rs 2026-06-12 02:28:02 +02:00
ca7ca51949 feat: per-entity federation privacy toggles for reviews and watchlist
- add federate_reviews + federate_watchlist to UserSettings (default true)
- new UserFederationSettingsQuery port with FederationFlags struct
- remove get_user_federate_goals from LocalApContentQuery
- gate ReviewLogged, ReviewUpdated, WatchlistEntryAdded, on_poster_synced on flags
- goals gating migrated to UserFederationSettingsQuery
- ReviewDeleted and WatchlistEntryRemoved ungated (tombstones always fire)
- sqlite + postgres migrations and adapter impls
- settings API and SPA toggles
2026-06-12 02:26:01 +02:00
33aa5bdab3 fmt
All checks were successful
CI / Check / Test (push) Successful in 38m21s
2026-06-12 01:46:16 +02:00
b844339795 fix(domain): ImportSession::new() generates own ID, add from_persistence() 2026-06-12 01:41:03 +02:00
cedb13d7a8 fix(domain): typed VOs in MovieEnrichmentRequested and PersonEnrichmentRequested 2026-06-12 01:34:47 +02:00
aec5f6b058 feat(dependencies): add async-stream and bytes to Cargo.toml and Cargo.lock
Some checks failed
CI / Check / Test (push) Has been cancelled
refactor(adapters): update diary.rs to use BoxStream from futures
2026-06-12 01:20:45 +02:00
d9234ecd11 fix(presentation): log errors in diary export stream 2026-06-12 01:18:54 +02:00
010ee404c8 feat(presentation): pipe diary export stream to Body::from_stream 2026-06-12 01:15:23 +02:00
d4c42f8567 feat(application): export_diary::execute returns BoxStream<Bytes> 2026-06-12 01:11:36 +02:00
9c44330f14 feat(adapters): stream_user_history in SQLite and Postgres diary adapters 2026-06-12 01:10:21 +02:00
2fa118570f feat(export): stream_entries — CSV/JSON streaming via BoxStream<Bytes> 2026-06-12 01:08:11 +02:00
ded7517a8a feat(domain): DiaryRepository::stream_user_history, DiaryExporter::stream_entries 2026-06-12 01:05:32 +02:00
bf272bf8d9 perf(import): parallelize row processing with JoinSet + Semaphore (limit 10)
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:40:27 +02:00
6f34b7b5ec fix(worker): nack on transient handler failures, ack on permanent
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:30:06 +02:00
17d4de461b feat(domain): DomainError::is_transient() for retry classification 2026-06-12 00:27:16 +02:00
40cb15e7cb refactor(postgres): split fat PostgresRepository into per-port structs
Some checks failed
CI / Check / Test (push) Has been cancelled
2026-06-12 00:00:15 +02:00
c80287bb9e refactor(presentation): use split sqlite repos in api_test 2026-06-11 23:52:14 +02:00
06ab5c8df1 refactor(sqlite): split fat SqliteMovieRepository into per-port structs 2026-06-11 23:49:55 +02:00
57520c00f3 refactor: move AppContext to presentation crate, structurally enforce boundary
All checks were successful
CI / Check / Test (push) Successful in 39m33s
2026-06-11 23:18:28 +02:00
b5cc7f8371 refactor(search): fix test to not use AppContext 2026-06-11 23:00:01 +02:00
9ca5ada924 refactor(auth): LoginDeps, RegisterDeps, RefreshDeps, RegisterAndLoginDeps, RefreshSessionCleanupJob 2026-06-11 22:58:42 +02:00
70d1f10e3d refactor(users): fix test files to not use AppContext 2026-06-11 22:49:44 +02:00
61980b0cfb refactor(users): GetProfileDeps, UpdateProfileDeps, scoped Arc deps 2026-06-11 22:47:17 +02:00
7bf5c47f5b refactor(diary): DeleteReviewDeps, GetMovieSocialPageDeps, GetActivityFeedDeps 2026-06-11 22:37:35 +02:00
ddf100cfc2 refactor(wrapup): scoped deps — HandleWrapUpRequestedDeps, flat-Arc jobs 2026-06-11 22:29:30 +02:00
cdff0de53d refactor(movies): collapse single-field deps structs to Arc params 2026-06-11 22:17:09 +02:00
1e62f12903 refactor(movies): EnrichMovieDeps, ReindexSearchDeps, SyncPosterDeps, SearchReindexHandler, EnrichmentStalenessJob 2026-06-11 22:13:25 +02:00
66bd138927 refactor(person): EnrichPersonDeps + GetPersonDeps, PersonEnrichmentHandler 2026-06-11 22:05:38 +02:00
b29f3020e6 refactor(integrations): IngestWatchEventDeps, scoped Arc deps, WatchEventCleanupJob 2026-06-11 22:01:15 +02:00
76edd52bb0 refactor(import): fix test files to not use AppContext 2026-06-11 21:51:24 +02:00
b5ff43d9dc refactor(import): scoped Arc deps, ImportSessionCleanupJob 2026-06-11 21:49:15 +02:00
b552c1d156 refactor(watchlist): WatchlistAddDeps, scoped Arc deps 2026-06-11 21:40:48 +02:00
f006ba00a8 refactor(goals): scoped Arc deps instead of AppContext 2026-06-11 21:36:11 +02:00
2b295e10ba refactor(search): scoped Arc dep instead of AppContext 2026-06-11 21:32:19 +02:00
217 changed files with 5652 additions and 4327 deletions

32
Cargo.lock generated
View File

@@ -314,6 +314,7 @@ name = "application"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"domain", "domain",
"futures", "futures",
@@ -567,6 +568,28 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "async-task" name = "async-task"
version = "4.7.1" version = "4.7.1"
@@ -1822,9 +1845,12 @@ dependencies = [
name = "export" name = "export"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-stream",
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"domain", "domain",
"futures",
"serde_json", "serde_json",
"tokio", "tokio",
"uuid", "uuid",
@@ -3848,9 +3874,12 @@ name = "postgres"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream",
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"domain", "domain",
"futures",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
@@ -5124,9 +5153,12 @@ name = "sqlite"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream",
"async-trait", "async-trait",
"bytes",
"chrono", "chrono",
"domain", "domain",
"futures",
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",

View File

@@ -38,6 +38,7 @@ resolver = "2"
tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] } tokio = { version = "1.0", features = ["macros", "net", "rt", "rt-multi-thread", "sync", "time"] }
bytes = "1" bytes = "1"
futures = "0.3" futures = "0.3"
async-stream = "0.3"
dotenvy = "0.15" dotenvy = "0.15"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View File

@@ -1,9 +1,18 @@
.DEFAULT_GOAL := check .DEFAULT_GOAL := check
# Run the full local check suite — same order as CI would. # Run the full local check suite — same order as CI would.
check: fmt-check clippy test check: fmt-check clippy test check-appcontext
@echo "✅ All checks passed" @echo "✅ All checks passed"
# Enforce that no application use case imports AppContext (god-object guard).
check-appcontext:
@if grep -rn "AppContext" crates/application/src --include="*.rs" | grep -q .; then \
echo "❌ AppContext found in application crate:"; \
grep -rn "AppContext" crates/application/src --include="*.rs"; \
exit 1; \
fi
@echo "✅ No AppContext in application crate"
# Apply rustfmt to all files. # Apply rustfmt to all files.
fmt: fmt:
cargo fmt cargo fmt

137
README.md
View File

@@ -1,6 +1,49 @@
# Movies Diary # Movies Diary
A self-hosted, server-side rendered movie logging system with a full REST API. Built in Rust — no JavaScript in the HTML interface, just HTML forms and an RSS feed. Designed to run as a lightweight widget embedded on a personal site or as a backend for third-party clients. A self-hosted movie diary built in Rust. Ships a classic server-rendered HTML interface (no JavaScript) alongside a full React SPA, both backed by the same REST API. Federates over ActivityPub so reviews reach the Fediverse. Supports Jellyfin and Plex auto-import, full-text search, annual wrap-ups, goals, and bulk import from Letterboxd, IMDb, and other sources. Runs on SQLite or PostgreSQL.
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Built with Rust](https://img.shields.io/badge/built_with-Rust-orange.svg?logo=rust)](https://www.rust-lang.org/)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/)
[![ActivityPub](https://img.shields.io/badge/ActivityPub-federated-5b5ea6)](https://activitypub.rocks/)
[![SQLite](https://img.shields.io/badge/database-SQLite%20%7C%20PostgreSQL-003B57?logo=sqlite&logoColor=white)](https://www.sqlite.org/)
---
## Table of Contents
- [Quick Start](#quick-start)
- [Features](#features)
- [Screenshots](#screenshots)
- [Architecture](#architecture)
- [Prerequisites](#prerequisites)
- [Configuration](#configuration)
- [Run](#run)
- [API](#api)
- [SPA](#spa)
- [Development](#development)
- [Test](#test)
- [Docker](#docker)
- [Media Server Integration](#media-server-integration)
- [Annual Wrap-Up](#annual-wrap-up)
- [Contributing](#contributing)
- [License](#license)
---
## Quick Start
The fastest way to run Movies Diary is via Docker Compose:
```bash
cp .env.example .env
# Set JWT_SECRET and OMDB_API_KEY (or TMDB_API_KEY) in .env
docker compose up -d
```
Open `http://localhost:3000`. The HTTP server and background worker start together; data is persisted in a Docker volume.
---
## Features ## Features
@@ -28,6 +71,18 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B
- Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback - Single-page app at `/app/` — React + TanStack Router + shadcn/ui, built with Vite, served from the backend with client-side routing fallback
- Terminal UI client (`crates/tui`, deprecated) for logging reviews, bulk CSV import, and diary browsing - Terminal UI client (`crates/tui`, deprecated) for logging reviews, bulk CSV import, and diary browsing
## Screenshots
> SPA at `/app/` — React + TanStack Router + shadcn/ui
| Feed | Movie | Person |
|------|-------|--------|
| ![Feed](screenshots/feed.jpeg) | ![Movie detail](screenshots/movie.jpeg) | ![Person detail](screenshots/person.jpeg) |
| Profile | Wrap-Up | Wrap-Up card |
|---------|---------|--------------|
| ![Profile](screenshots/profile.jpeg) | ![Wrap-Up stats](screenshots/wrapup-stats.jpeg) | ![Wrap-Up shareable card](screenshots/wrapup-card.jpeg) |
## Architecture ## Architecture
Hexagonal (Ports & Adapters) with Domain-Driven Design: Hexagonal (Ports & Adapters) with Domain-Driven Design:
@@ -75,59 +130,35 @@ spa/ — React SPA (TanStack Router + shadcn/ui + Vite); served a
- Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO) - Poster storage: local filesystem (zero deps) or an S3-compatible object store (e.g. MinIO)
- An [OMDb API key](https://www.omdbapi.com/apikey.aspx) - An [OMDb API key](https://www.omdbapi.com/apikey.aspx)
## Environment Variables ## Configuration
A `.env.example` file is provided at the repo root — copy it to `.env` and fill in your values. Copy `.env.example` to `.env` and set the values below. Required fields must be set before the server will start.
```env | Variable | Default | Required | Description |
# Database |---|---|---|---|
DATABASE_URL=sqlite://movies.db | `DATABASE_URL` | `sqlite://movies.db` | Yes | SQLite or PostgreSQL connection string |
| `JWT_SECRET` | — | Yes | Secret for JWT signing — use a long random string |
# Authentication | `OMDB_API_KEY` | — | Yes | [OMDb](https://www.omdbapi.com/apikey.aspx) key for movie metadata |
JWT_SECRET=change-me | `TMDB_API_KEY` | — | No | [TMDb](https://www.themoviedb.org/settings/api) key — enables cast, crew, genres, enrichment |
| `BASE_URL` | — | Yes | Public URL of your instance (used for ActivityPub actor URLs) |
# OMDb metadata | `IMAGE_STORAGE_BACKEND` | `local` | No | `local` or `s3` |
OMDB_API_KEY=your-key | `IMAGE_STORAGE_PATH` | `./images` | No | Path for local image storage |
| `MINIO_ENDPOINT` | — | S3 only | S3-compatible endpoint (e.g. `http://localhost:9000`) |
# TMDb metadata + enrichment (optional — enables full cast/crew/genre data) | `MINIO_BUCKET` | — | S3 only | Bucket name |
# TMDB_API_KEY=your-key | `MINIO_REGION` | — | S3 only | Region (e.g. `minio`) |
| `MINIO_ACCESS_KEY_ID` | — | S3 only | Access key ID |
# Public base URL (used for ActivityPub actor URLs and canonical links) | `MINIO_SECRET_ACCESS_KEY` | — | S3 only | Secret access key |
BASE_URL=https://yourdomain.example.com | `IMAGE_CONVERSION_ENABLED` | `false` | No | Convert stored images to AVIF or WebP |
| `IMAGE_CONVERSION_FORMAT` | `avif` | No | `avif` or `webp` |
# Image storage — pick one backend: | `HOST` | `0.0.0.0` | No | Bind address |
| `PORT` | `3000` | No | HTTP port |
# Option A: local filesystem (zero deps) | `RATE_LIMIT` | `60` | No | Requests per minute per IP |
IMAGE_STORAGE_BACKEND=local | `ALLOW_REGISTRATION` | `true` | No | Set `false` to disable new sign-ups |
IMAGE_STORAGE_PATH=./images | `SECURE_COOKIES` | `true` | No | Must be `true` when serving over HTTPS |
| `RUST_LOG` | — | No | Log verbosity (e.g. `presentation=info,worker=info`) |
# Option B: S3-compatible (MinIO, AWS S3, etc.) | `CORS_ORIGINS` | `*` | No | Comma-separated allowed origins for SPA dev |
# IMAGE_STORAGE_BACKEND=s3 | `EVENT_BUS_BACKEND` | `db` | No | `db` (default) or `nats` |
# MINIO_ENDPOINT=http://localhost:9000 | `NATS_URL` | — | NATS only | NATS connection URL (e.g. `nats://localhost:4222`) |
# MINIO_BUCKET=posters
# MINIO_REGION=minio
# MINIO_ACCESS_KEY_ID=minioadmin
# MINIO_SECRET_ACCESS_KEY=minioadmin
# Image conversion (optional — converts stored images to AVIF or WebP to save space)
# IMAGE_CONVERSION_ENABLED=false
# IMAGE_CONVERSION_FORMAT=avif # avif or webp
# Optional
HOST=0.0.0.0
PORT=3000
RATE_LIMIT=60 # requests per minute per IP (default: 60)
ALLOW_REGISTRATION=true # set to false to disable new sign-ups
SECURE_COOKIES=true # set when serving over HTTPS
RUST_LOG=presentation=info,tower_http=info,worker=info,application=info
# CORS — comma-separated origins for SPA dev (omit or "*" for any)
# CORS_ORIGINS=http://localhost:5173
# Event bus — "db" (default, uses same database) or "nats"
EVENT_BUS_BACKEND=db
# NATS_URL=nats://localhost:4222 # required when EVENT_BUS_BACKEND=nats
```
The `worker` binary must run alongside `presentation` to process events: The `worker` binary must run alongside `presentation` to process events:
@@ -153,6 +184,8 @@ Interactive API documentation is available at runtime:
- **Swagger UI** — `http://localhost:3000/docs` - **Swagger UI** — `http://localhost:3000/docs`
- **Scalar** — `http://localhost:3000/scalar` - **Scalar** — `http://localhost:3000/scalar`
An [Insomnia](https://insomnia.rest/) collection covering all endpoints is included at [`movies-diary.insomnia.json`](movies-diary.insomnia.json). Import it via **File → Import**, set `base_url` and `token` in the environment, and you're ready to go.
## SPA ## SPA
The single-page app lives in `spa/` and is served at `/app/` by the backend. For local development: The single-page app lives in `spa/` and is served at `/app/` by the backend. For local development:

View File

@@ -4,7 +4,7 @@ use domain::ports::EventHandler;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::LocalApContentQuery, ports::{LocalApContentQuery, UserFederationSettingsQuery},
value_objects::{MovieId, ReviewId, UserId}, value_objects::{MovieId, ReviewId, UserId},
}; };
use std::sync::Arc; use std::sync::Arc;
@@ -17,6 +17,7 @@ use crate::urls::{actor_url, goal_url, review_url};
pub struct ActivityPubEventHandler { pub struct ActivityPubEventHandler {
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>, content_query: Arc<dyn LocalApContentQuery>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
} }
@@ -24,11 +25,13 @@ impl ActivityPubEventHandler {
pub fn new( pub fn new(
ap_service: Arc<ActivityPubService>, ap_service: Arc<ActivityPubService>,
content_query: Arc<dyn LocalApContentQuery>, content_query: Arc<dyn LocalApContentQuery>,
federation_settings: Arc<dyn UserFederationSettingsQuery>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { Self {
ap_service, ap_service,
content_query, content_query,
federation_settings,
base_url, base_url,
} }
} }
@@ -131,6 +134,19 @@ impl EventHandler for ActivityPubEventHandler {
impl ActivityPubEventHandler { impl ActivityPubEventHandler {
async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? { let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
@@ -184,6 +200,19 @@ impl ActivityPubEventHandler {
user_id: &UserId, user_id: &UserId,
review_id: &ReviewId, review_id: &ReviewId,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
return Ok(());
}
let review = match self.content_query.get_review_by_id(review_id).await? { let review = match self.content_query.get_review_by_id(review_id).await? {
Some(r) => r, Some(r) => r,
None => return Ok(()), None => return Ok(()),
@@ -250,6 +279,19 @@ impl ActivityPubEventHandler {
external_metadata_id: &Option<String>, external_metadata_id: &Option<String>,
added_at: &chrono::NaiveDateTime, added_at: &chrono::NaiveDateTime,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.watchlist {
return Ok(());
}
use crate::urls::watchlist_entry_url; use crate::urls::watchlist_entry_url;
let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value());
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
@@ -316,6 +358,20 @@ impl ActivityPubEventHandler {
for entry in entries { for entry in entries {
let review = entry.review(); let review = entry.review();
let user_id = review.user_id(); let user_id = review.user_id();
let flags = self
.federation_settings
.get_federation_flags(user_id)
.await
.unwrap_or(domain::ports::FederationFlags {
goals: true,
reviews: true,
watchlist: true,
});
if !flags.reviews {
continue;
}
let ap_id = review_url(&self.base_url, review.id()); let ap_id = review_url(&self.base_url, review.id());
let actor = actor_url(&self.base_url, user_id.value()); let actor = actor_url(&self.base_url, user_id.value());
@@ -343,12 +399,16 @@ impl ActivityPubEventHandler {
user_id: &UserId, user_id: &UserId,
year: u16, year: u16,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if !self let flags = self
.content_query .federation_settings
.get_user_federate_goals(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(false) .unwrap_or(domain::ports::FederationFlags {
{ goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(()); return Ok(());
} }
let Some((goal, current)) = self let Some((goal, current)) = self
@@ -384,12 +444,16 @@ impl ActivityPubEventHandler {
target_count: u32, target_count: u32,
is_create: bool, is_create: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
if !self let flags = self
.content_query .federation_settings
.get_user_federate_goals(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(false) .unwrap_or(domain::ports::FederationFlags {
{ goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(()); return Ok(());
} }
let current = self let current = self
@@ -418,12 +482,16 @@ impl ActivityPubEventHandler {
} }
async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> { async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> {
if !self let flags = self
.content_query .federation_settings
.get_user_federate_goals(user_id) .get_federation_flags(user_id)
.await .await
.unwrap_or(false) .unwrap_or(domain::ports::FederationFlags {
{ goals: true,
reviews: true,
watchlist: true,
});
if !flags.goals {
return Ok(()); return Ok(());
} }
let ap_id = goal_url(&self.base_url, user_id.value(), year); let ap_id = goal_url(&self.base_url, user_id.value(), year);

View File

@@ -52,6 +52,7 @@ pub struct ActivityPubDeps {
pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal_repo: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>, pub local_ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, pub user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub base_url: String, pub base_url: String,
pub allow_registration: bool, pub allow_registration: bool,
pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>, pub event_publisher: std::sync::Arc<dyn domain::ports::EventPublisher>,
@@ -68,6 +69,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
remote_goal_repo, remote_goal_repo,
local_ap_content, local_ap_content,
user_repo, user_repo,
federation_settings,
base_url, base_url,
allow_registration, allow_registration,
event_publisher, event_publisher,
@@ -129,6 +131,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result<ActivityPubWire> {
let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new(
std::sync::Arc::clone(&concrete), std::sync::Arc::clone(&concrete),
local_ap_content, local_ap_content,
federation_settings,
base_url, base_url,
)) as std::sync::Arc<dyn domain::ports::EventHandler>; )) as std::sync::Arc<dyn domain::ports::EventHandler>;

View File

@@ -2,7 +2,7 @@ use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::PersonId, models::{ExternalPersonId, PersonId},
value_objects::{ value_objects::{
ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId, ExternalMetadataId, GoalId, MovieId, PosterPath, Rating, ReviewId, UserId, WrapUpId,
}, },
@@ -210,7 +210,7 @@ impl From<&DomainEvent> for EventPayload {
external_metadata_id, external_metadata_id,
} => EventPayload::MovieEnrichmentRequested { } => EventPayload::MovieEnrichmentRequested {
movie_id: movie_id.value().to_string(), movie_id: movie_id.value().to_string(),
external_metadata_id: external_metadata_id.clone(), external_metadata_id: external_metadata_id.value().to_string(),
}, },
DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() }, DomainEvent::ImageStored { key } => EventPayload::ImageStored { key: key.clone() },
DomainEvent::WatchlistEntryAdded { DomainEvent::WatchlistEntryAdded {
@@ -322,7 +322,7 @@ impl From<&DomainEvent> for EventPayload {
external_person_id, external_person_id,
} => EventPayload::PersonEnrichmentRequested { } => EventPayload::PersonEnrichmentRequested {
person_id: person_id.value().to_string(), person_id: person_id.value().to_string(),
external_person_id: external_person_id.clone(), external_person_id: external_person_id.value().to_string(),
}, },
} }
} }
@@ -391,7 +391,8 @@ impl TryFrom<EventPayload> for DomainEvent {
external_metadata_id, external_metadata_id,
} => Ok(DomainEvent::MovieEnrichmentRequested { } => Ok(DomainEvent::MovieEnrichmentRequested {
movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?), movie_id: MovieId::from_uuid(parse_uuid(&movie_id, "movie_id")?),
external_metadata_id, external_metadata_id: ExternalMetadataId::new(external_metadata_id)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
}), }),
EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }), EventPayload::ImageStored { key } => Ok(DomainEvent::ImageStored { key }),
EventPayload::WatchlistEntryAdded { EventPayload::WatchlistEntryAdded {
@@ -514,7 +515,7 @@ impl TryFrom<EventPayload> for DomainEvent {
external_person_id, external_person_id,
} => Ok(DomainEvent::PersonEnrichmentRequested { } => Ok(DomainEvent::PersonEnrichmentRequested {
person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?), person_id: PersonId::from_uuid(parse_uuid(&person_id, "person_id")?),
external_person_id, external_person_id: ExternalPersonId::new(external_person_id),
}), }),
} }
} }

View File

@@ -8,6 +8,9 @@ domain = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }
[dev-dependencies] [dev-dependencies]
uuid = { workspace = true } uuid = { workspace = true }

View File

@@ -1,30 +1,70 @@
use async_trait::async_trait; use bytes::Bytes;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{DiaryEntry, ExportFormat}, models::{DiaryEntry, ExportFormat},
ports::DiaryExporter, ports::DiaryExporter,
}; };
use futures::stream::BoxStream;
pub struct ExportAdapter; pub struct ExportAdapter;
#[async_trait]
impl DiaryExporter for ExportAdapter { impl DiaryExporter for ExportAdapter {
async fn serialize_entries( fn stream_entries(
&self, &self,
entries: &[DiaryEntry], stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
format: ExportFormat, format: ExportFormat,
) -> Result<Vec<u8>, DomainError> { ) -> BoxStream<'static, Result<Bytes, DomainError>> {
match format { match format {
ExportFormat::Csv => serialize_csv(entries), ExportFormat::Csv => stream_csv(stream),
ExportFormat::Json => serialize_json(entries), ExportFormat::Json => stream_json(stream),
} }
} }
} }
fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> { fn stream_csv(
let mut out = entries: BoxStream<'static, Result<DiaryEntry, DomainError>>,
String::from("title,year,director,rating,comment,watched_at,external_metadata_id\n"); ) -> BoxStream<'static, Result<Bytes, DomainError>> {
for e in entries { use futures::StreamExt;
let header = futures::stream::once(async {
Ok(Bytes::from_static(
b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
))
});
let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
Box::pin(header.chain(rows))
}
fn stream_json(
stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
) -> BoxStream<'static, Result<Bytes, DomainError>> {
Box::pin(async_stream::stream! {
futures::pin_mut!(stream);
let mut is_first = true;
while let Some(r) = futures::StreamExt::next(&mut stream).await {
match r {
Err(e) => { yield Err(e); return; }
Ok(entry) => {
let json = serde_json::to_string(&entry_to_json(&entry))
.map_err(|e| DomainError::InfrastructureError(e.to_string()));
let json = match json {
Ok(s) => s,
Err(e) => { yield Err(e); return; }
};
let prefix = if is_first { "[" } else { "," };
is_first = false;
yield Ok(Bytes::from(format!("{}{}", prefix, json)));
}
}
}
if is_first {
yield Ok(Bytes::from_static(b"[]"));
} else {
yield Ok(Bytes::from_static(b"]"));
}
})
}
fn csv_row(e: &DiaryEntry) -> String {
let title = csv_escape(e.movie().title().value()); let title = csv_escape(e.movie().title().value());
let year = e.movie().release_year().value(); let year = e.movie().release_year().value();
let director = e.movie().director().map(csv_escape).unwrap_or_default(); let director = e.movie().director().map(csv_escape).unwrap_or_default();
@@ -40,12 +80,10 @@ fn serialize_csv(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> {
.external_metadata_id() .external_metadata_id()
.map(|id| id.value().to_string()) .map(|id| id.value().to_string())
.unwrap_or_default(); .unwrap_or_default();
out.push_str(&format!( format!(
"{},{},{},{},{},{},{}\n", "{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id title, year, director, rating, comment, watched_at, ext_id
)); )
}
Ok(out.into_bytes())
} }
fn csv_escape(s: &str) -> String { fn csv_escape(s: &str) -> String {
@@ -56,22 +94,16 @@ fn csv_escape(s: &str) -> String {
} }
} }
fn serialize_json(entries: &[DiaryEntry]) -> Result<Vec<u8>, DomainError> { fn entry_to_json(e: &DiaryEntry) -> serde_json::Value {
let arr: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({ serde_json::json!({
"title": e.movie().title().value(), "title": e.movie().title().value(),
"year": e.movie().release_year().value(), "year": e.movie().release_year().value(),
"director": e.movie().director(), "director": e.movie().director(),
"rating": e.review().rating().value(), "rating": e.review().rating().value(),
"comment": e.review().comment().map(|c| c.value()), "comment": e.review().comment().map(|c| c.value().to_string()),
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(), "watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()), "external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
}) })
})
.collect();
serde_json::to_vec_pretty(&arr).map_err(|e| DomainError::InfrastructureError(e.to_string()))
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -5,6 +5,27 @@ use domain::{
value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear}, value_objects::{ExternalMetadataId, MovieTitle, Rating, ReleaseYear},
}; };
async fn collect_stream(
stream: futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>,
) -> Vec<u8> {
use futures::StreamExt;
let mut out = Vec::new();
futures::pin_mut!(stream);
while let Some(chunk) = stream.next().await {
out.extend_from_slice(&chunk.unwrap());
}
out
}
fn entry_stream(
entries: Vec<domain::models::DiaryEntry>,
) -> futures::stream::BoxStream<
'static,
Result<domain::models::DiaryEntry, domain::errors::DomainError>,
> {
Box::pin(futures::stream::iter(entries.into_iter().map(Ok)))
}
fn make_entry( fn make_entry(
title: &str, title: &str,
year: u16, year: u16,
@@ -55,10 +76,8 @@ async fn csv_has_header_and_one_row() {
5, 5,
Some("great"), Some("great"),
); );
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!( assert!(
text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n") text.starts_with("title,year,director,rating,comment,watched_at,external_metadata_id\n")
@@ -75,10 +94,8 @@ async fn csv_has_header_and_one_row() {
async fn csv_escapes_commas_in_title() { async fn csv_escapes_commas_in_title() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry("Tár, A Film", 2022, None, 4, None); let entry = make_entry("Tár, A Film", 2022, None, 4, None);
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("\"Tár, A Film\"")); assert!(text.contains("\"Tár, A Film\""));
} }
@@ -87,10 +104,8 @@ async fn csv_escapes_commas_in_title() {
async fn json_is_valid_array() { async fn json_is_valid_array() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None); let entry = make_entry("Dune", 2021, Some("Denis Villeneuve"), 5, None);
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Json) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap(); let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr.len(), 1); assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["title"], "Dune"); assert_eq!(arr[0]["title"], "Dune");
@@ -104,27 +119,23 @@ async fn json_is_valid_array() {
async fn external_metadata_id_included_when_present() { async fn external_metadata_id_included_when_present() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748")); let entry = make_entry_full("Alien", 1979, None, 5, None, Some("tt0078748"));
let bytes = adapter let bytes =
.serialize_entries(&[entry], ExportFormat::Json) collect_stream(adapter.stream_entries(entry_stream(vec![entry]), ExportFormat::Json)).await;
.await
.unwrap();
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap(); let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert_eq!(arr[0]["external_metadata_id"], "tt0078748"); assert_eq!(arr[0]["external_metadata_id"], "tt0078748");
let bytes = adapter let bytes = collect_stream(adapter.stream_entries(
.serialize_entries( entry_stream(vec![make_entry_full(
&[make_entry_full(
"Alien", "Alien",
1979, 1979,
None, None,
5, 5,
None, None,
Some("tt0078748"), Some("tt0078748"),
)], )]),
ExportFormat::Csv, ExportFormat::Csv,
) ))
.await .await;
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert!(text.contains("tt0078748")); assert!(text.contains("tt0078748"));
} }
@@ -132,13 +143,20 @@ async fn external_metadata_id_included_when_present() {
#[tokio::test] #[tokio::test]
async fn empty_entries_returns_csv_header_only() { async fn empty_entries_returns_csv_header_only() {
let adapter = ExportAdapter; let adapter = ExportAdapter;
let bytes = adapter let bytes =
.serialize_entries(&[], ExportFormat::Csv) collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Csv)).await;
.await
.unwrap();
let text = String::from_utf8(bytes).unwrap(); let text = String::from_utf8(bytes).unwrap();
assert_eq!( assert_eq!(
text, text,
"title,year,director,rating,comment,watched_at,external_metadata_id\n" "title,year,director,rating,comment,watched_at,external_metadata_id\n"
); );
} }
#[tokio::test]
async fn empty_json_is_valid_empty_array() {
let adapter = ExportAdapter;
let bytes =
collect_stream(adapter.stream_entries(entry_stream(vec![]), ExportFormat::Json)).await;
let arr: Vec<serde_json::Value> = serde_json::from_slice(&bytes).unwrap();
assert!(arr.is_empty());
}

View File

@@ -123,7 +123,7 @@ impl EventHandler for PosterSyncHandler {
if already_has_poster { if already_has_poster {
return Ok(()); return Ok(());
} }
(movie_id.value(), external_metadata_id.clone()) (movie_id.value(), external_metadata_id.value().to_owned())
} }
_ => return Ok(()), _ => return Ok(()),
}; };

View File

@@ -20,3 +20,6 @@ async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -229,23 +229,6 @@ impl LocalApContentQuery for PostgresApContentQuery {
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress( async fn get_goal_with_progress(
&self, &self,
user_id: &UserId, user_id: &UserId,

View File

@@ -0,0 +1,540 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
collections::{PageParams, Paginated},
},
ports::DiaryRepository,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
use sqlx::PgPool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
pub struct PostgresDiaryRepository {
pool: PgPool,
}
impl PostgresDiaryRepository {
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 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),
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)
}
}
}
async fn fetch_all_diary_rows(
&self,
sort: &SortDirection,
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",
};
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
LIMIT $1 OFFSET $2",
order
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
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",
};
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = $1
ORDER BY {}
LIMIT $2 OFFSET $3",
order
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(movie_id)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn count_user_diary_entries(
&self,
user_id: &str,
search: Option<&str>,
) -> 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()
} else {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1"
.to_string()
};
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)
}
async fn fetch_user_diary_rows(
&self,
user_id: &str,
sort: &SortDirection,
search: Option<&str>,
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",
};
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N
let mut p: i32 = 1; // $1 is user_id
let search_clause = if has_search {
p += 1;
format!(" AND m.title ILIKE '%' || ${} || '%'", p)
} else {
String::new()
};
p += 1;
let limit_param = format!("${}", p);
p += 1;
let offset_param = format!("${}", p);
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1{}
ORDER BY {}
LIMIT {} OFFSET {}",
search_clause, order_clause, limit_param, offset_param
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
}
#[async_trait]
impl DiaryRepository for PostgresDiaryRepository {
async fn query_diary(
&self,
filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64;
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
(None, None) => tokio::try_join!(
self.count_diary_entries(None),
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
)?,
(Some(id), None) => {
let id_str = id.value().to_string();
tokio::try_join!(
self.count_diary_entries(Some(id_str.as_str())),
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
)?
}
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
)?
}
(Some(_), Some(_)) => {
return Err(DomainError::ValidationError(
"Combined movie_id + user_id filter not supported".into(),
));
}
};
let items = rows
.into_iter()
.map(DiaryRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: filter.page.limit,
offset: filter.page.offset,
})
}
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None)
.await
}
async fn query_activity_feed_filtered(
&self,
page: &PageParams,
sort_by: &domain::ports::FeedSortBy,
search: Option<&str>,
following: Option<&domain::ports::FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
use domain::ports::FeedSortBy;
let limit = page.limit as i64;
let offset = page.offset as i64;
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
// Dynamic param counter
let mut p: i32 = 0;
let mut next_param = || {
p += 1;
format!("${}", p)
};
let mut where_parts = vec!["1=1".to_string()];
if has_search {
let pn = next_param();
where_parts.push(format!("m.title ILIKE '%' || {} || '%'", pn));
}
if let Some(f) = following {
let local_params: Vec<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
let remote_params: Vec<String> =
f.remote_actor_urls.iter().map(|_| next_param()).collect();
let local_in = if local_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
local_params.join(", ")
};
let remote_in = if remote_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
remote_params.join(", ")
};
where_parts.push(format!(
"(r.user_id IN ({}) OR r.remote_actor_url IN ({}))",
local_in, remote_in
));
}
let limit_param = next_param();
let offset_param = next_param();
let order_clause = match sort_by {
FeedSortBy::Date => "r.watched_at DESC",
FeedSortBy::DateAsc => "r.watched_at ASC",
FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC",
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
let where_clause = where_parts.join(" AND ");
// Reset counter for count query (reuse same where_clause string but re-bind)
// We need a separate counter for count SQL — but since where_clause is already built
// with the right $N references, both queries share it.
let count_sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE {}",
where_clause
);
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,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
COALESCE(u.email, r.remote_actor_url) AS user_email
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE {}
ORDER BY {}
LIMIT {} OFFSET {}",
where_clause, order_clause, limit_param, offset_param
);
// Bind helper closure — binds search + following params in order
macro_rules! bind_filter_params {
($q:expr) => {{
let mut q = $q;
if has_search {
q = q.bind(search.unwrap());
}
if let Some(f) = following {
for uid in &f.local_user_ids {
q = q.bind(uid.to_string());
}
for url in &f.remote_actor_urls {
q = q.bind(url.as_str());
}
}
q
}};
}
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 rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
let id_str = movie_id.value().to_string();
let movie = sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.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,
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 movie_id = $1 ORDER BY watched_at ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
}
async fn get_user_history(&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
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
fn stream_user_history(
&self,
user_id: UserId,
) -> BoxStream<'static, Result<DiaryEntry, DomainError>> {
let pool = self.pool.clone();
let uid = user_id.value().to_string();
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,
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
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch(&pool);
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)),
};
}
})
}
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS FLOAT)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = $1",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
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,
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = $1
ORDER BY r.watched_at DESC
LIMIT $2 OFFSET $3",
)
.bind(&id_str)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
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)
}
}

View File

@@ -5,6 +5,7 @@ use domain::{
models::{ models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile, AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform}, import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
}, },
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
@@ -266,7 +267,7 @@ impl PostgresImportSessionRepository {
Ok(js.into_iter().map(annotated_from_json).collect()) Ok(js.into_iter().map(annotated_from_json).collect())
}) })
.transpose()?; .transpose()?;
Ok(ImportSession { Ok(ImportSession::from_persistence(PersistedImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>() id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -281,7 +282,7 @@ impl PostgresImportSessionRepository {
row_results, row_results,
created_at, created_at,
expires_at, expires_at,
}) }))
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::MovieRepository,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::PgPool;
use crate::models::{MovieRow, MovieSummaryRow};
pub struct PostgresMovieRepository {
pool: PgPool,
}
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()
}
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());
let title = movie.title().value();
let release_year = movie.release_year().value() as i64;
let director = movie.director();
let poster_path = movie.poster_path().map(|p| p.value().to_string());
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT(id) DO UPDATE SET
external_metadata_id = excluded.external_metadata_id,
title = excluded.title,
release_year = excluded.release_year,
director = excluded.director,
poster_path = excluded.poster_path",
)
.bind(&id)
.bind(&external_metadata_id)
.bind(title)
.bind(release_year)
.bind(director)
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
let id = movie_id.value().to_string();
sqlx::query("DELETE FROM movies WHERE id = $1")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn existing_external_ids(
&self,
ids: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
if ids.is_empty() {
return Ok(Default::default());
}
let vals: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT external_metadata_id FROM movies WHERE external_metadata_id = ANY($1)",
)
.bind(&vals)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
async fn existing_title_year_pairs(
&self,
pairs: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
if pairs.is_empty() {
return Ok(Default::default());
}
let titles: Vec<&str> = pairs.iter().map(|(t, _)| t.value()).collect();
let years: Vec<i64> = pairs.iter().map(|(_, y)| y.value() as i64).collect();
use sqlx::Row;
let rows = sqlx::query(
"SELECT DISTINCT m.title, m.release_year FROM movies m \
INNER JOIN unnest($1::text[], $2::bigint[]) AS p(title, release_year) \
ON m.title = p.title AND m.release_year = p.release_year",
)
.bind(&titles)
.bind(&years)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(rows
.into_iter()
.map(|r| {
let t: String = r.get("title");
let y: i64 = r.get("release_year");
(t, y as u16)
})
.collect())
}
async fn list_movies(
&self,
page: &PageParams,
filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT $4 OFFSET $5",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
}

View File

@@ -0,0 +1,112 @@
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};
pub struct PostgresReviewRepository {
pool: PgPool,
}
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> {
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();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let created_at = datetime_to_str(review.created_at());
let remote_actor_url = match review.source() {
ReviewSource::Local => None,
ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
};
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)",
)
.bind(&id)
.bind(&movie_id)
.bind(&user_id)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
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(),
})
}
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 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)?;
Ok(())
}
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let uid = user_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 user_id = $1 ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect()
}
}

View File

@@ -0,0 +1,153 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
ports::StatsRepository,
value_objects::UserId,
};
use sqlx::PgPool;
use crate::format_year_month;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
pub struct PostgresStatsRepository {
pool: PgPool,
}
impl PostgresStatsRepository {
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 fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>(
r#"SELECT COUNT(DISTINCT movie_id) AS total,
AVG(rating::float) AS avg_rating
FROM reviews WHERE user_id = $1"#,
)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_favorite_director(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT m.director
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 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_most_active_month(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month
FROM reviews
WHERE user_id = $1
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)
}
}
#[async_trait]
impl StatsRepository for PostgresStatsRepository {
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
let uid = user_id.value().to_string();
let (totals, fav_director, most_active) = tokio::try_join!(
self.fetch_user_totals(&uid),
self.fetch_user_favorite_director(&uid),
self.fetch_user_most_active_month(&uid)
)?;
let most_active_month = most_active.map(|ym| format_year_month(&ym));
Ok(UserStats {
total_movies: totals.total,
avg_rating: totals.avg_rating,
favorite_director: fav_director,
most_active_month,
})
}
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,
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
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)
)
.map_err(Self::map_err)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
let monthly_ratings = rating_rows
.into_iter()
.map(|r| MonthlyRating {
month_label: format_year_month(&r.month),
year_month: r.month,
avg_rating: r.avg_rating,
count: r.count,
})
.collect();
let top_directors = director_rows
.into_iter()
.map(|d| DirectorStat {
director: d.director,
count: d.count,
})
.collect();
Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
})
}
}

View File

@@ -1,6 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId, errors::DomainError,
models::UserSettings,
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
value_objects::UserId,
}; };
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
@@ -23,9 +26,10 @@ impl PostgresUserSettingsRepository {
impl UserSettingsRepository for PostgresUserSettingsRepository { impl UserSettingsRepository for PostgresUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> { async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let row = sqlx::query(
let row = "SELECT federate_goals, federate_reviews, federate_watchlist \
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = $1") FROM user_settings WHERE user_id = $1",
)
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
@@ -33,10 +37,14 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
match row { match row {
Some(r) => { Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0); let goals: bool = r.try_get("federate_goals").unwrap_or(true);
let reviews: bool = r.try_get("federate_reviews").unwrap_or(true);
let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true);
Ok(UserSettings::from_persistence( Ok(UserSettings::from_persistence(
user_id.clone(), user_id.clone(),
federate != 0, goals,
reviews,
watchlist,
)) ))
} }
None => Ok(UserSettings::new(user_id.clone())), None => Ok(UserSettings::new(user_id.clone())),
@@ -45,18 +53,52 @@ impl UserSettingsRepository for PostgresUserSettingsRepository {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string(); let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 };
sqlx::query( sqlx::query(
"INSERT INTO user_settings (user_id, federate_goals) VALUES ($1, $2) \ "INSERT INTO user_settings (user_id, federate_goals, federate_reviews, federate_watchlist) \
ON CONFLICT (user_id) DO UPDATE SET federate_goals = $2", VALUES ($1, $2, $3, $4) \
ON CONFLICT (user_id) DO UPDATE \
SET federate_goals = $2, federate_reviews = $3, federate_watchlist = $4",
) )
.bind(&uid) .bind(&uid)
.bind(federate) .bind(settings.federate_goals())
.bind(settings.federate_reviews())
.bind(settings.federate_watchlist())
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
} }
#[async_trait]
impl UserFederationSettingsQuery for PostgresUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = $1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let goals: bool = r.try_get("federate_goals").unwrap_or(true);
let reviews: bool = r.try_get("federate_reviews").unwrap_or(true);
let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true);
Ok(FederationFlags {
goals,
reviews,
watchlist,
})
}
None => Ok(FederationFlags {
goals: true,
reviews: true,
watchlist: true,
}),
}
}
}

View File

@@ -20,3 +20,6 @@ chrono = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
futures = { workspace = true }
bytes = { workspace = true }
async-stream = { workspace = true }

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE;

View File

@@ -5,7 +5,7 @@ use domain::{
ports::LocalApContentQuery, ports::LocalApContentQuery,
value_objects::{MovieId, ReviewId, UserId}, value_objects::{MovieId, ReviewId, UserId},
}; };
use sqlx::{Row, SqlitePool}; use sqlx::SqlitePool;
use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow}; use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow};
@@ -169,23 +169,6 @@ impl LocalApContentQuery for SqliteApContentQuery {
rows.into_iter().map(DiaryRow::into_domain).collect() rows.into_iter().map(DiaryRow::into_domain).collect()
} }
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = ?")
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let val: i64 = r.try_get("federate_goals").unwrap_or(0);
Ok(val != 0)
}
None => Ok(false),
}
}
async fn get_goal_with_progress( async fn get_goal_with_progress(
&self, &self,
user_id: &UserId, user_id: &UserId,

View File

@@ -0,0 +1,502 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, SortDirection,
collections::{PageParams, Paginated},
},
ports::DiaryRepository,
value_objects::{MovieId, UserId},
};
use futures::stream::BoxStream;
use sqlx::SqlitePool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
pub struct SqliteDiaryRepository {
pool: SqlitePool,
}
impl SqliteDiaryRepository {
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 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),
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)
}
}
}
async fn fetch_all_diary_rows(
&self,
sort: &SortDirection,
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",
};
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
ORDER BY {}
LIMIT ? OFFSET ?",
order_clause
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_movie_diary_rows(
&self,
movie_id: &str,
sort: &SortDirection,
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",
};
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.movie_id = ?
ORDER BY {}
LIMIT ? OFFSET ?",
order_clause
);
sqlx::query_as::<_, DiaryRow>(&sql)
.bind(movie_id)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
async fn count_user_diary_entries(
&self,
user_id: &str,
search: Option<&str>,
) -> 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()
} else {
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?"
.to_string()
};
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)
}
async fn fetch_user_diary_rows(
&self,
user_id: &str,
sort: &SortDirection,
search: Option<&str>,
limit: i64,
offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
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",
};
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?{}
ORDER BY {}
LIMIT ? OFFSET ?",
search_clause, order_clause
);
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search {
q = q.bind(search.unwrap());
}
q.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)
}
}
#[async_trait]
impl DiaryRepository for SqliteDiaryRepository {
async fn query_diary(
&self,
filter: &DiaryFilter,
) -> Result<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64;
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
(None, None) => tokio::try_join!(
self.count_diary_entries(None),
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
)?,
(Some(id), None) => {
let id_str = id.value().to_string();
tokio::try_join!(
self.count_diary_entries(Some(id_str.as_str())),
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
)?
}
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
tokio::try_join!(
self.count_user_diary_entries(&uid_str, search),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset)
)?
}
(Some(_), Some(_)) => {
return Err(DomainError::ValidationError(
"Combined movie_id + user_id filter not supported".into(),
));
}
};
let items = rows
.into_iter()
.map(DiaryRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: filter.page.limit,
offset: filter.page.offset,
})
}
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
self.query_activity_feed_filtered(page, &domain::ports::FeedSortBy::Date, None, None)
.await
}
async fn query_activity_feed_filtered(
&self,
page: &PageParams,
sort_by: &domain::ports::FeedSortBy,
search: Option<&str>,
following: Option<&domain::ports::FollowingFilter>,
) -> Result<Paginated<FeedEntry>, DomainError> {
use domain::ports::FeedSortBy;
let limit = page.limit as i64;
let offset = page.offset as i64;
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let mut where_parts = vec!["1=1".to_string()];
if has_search {
where_parts.push("m.title LIKE '%' || ? || '%'".to_string());
}
if let Some(f) = following {
let local_in = if f.local_user_ids.is_empty() {
"SELECT NULL WHERE 0".to_string()
} else {
f.local_user_ids
.iter()
.map(|_| "?")
.collect::<Vec<_>>()
.join(",")
};
let remote_in = if f.remote_actor_urls.is_empty() {
"SELECT NULL WHERE 0".to_string()
} else {
f.remote_actor_urls
.iter()
.map(|_| "?")
.collect::<Vec<_>>()
.join(",")
};
where_parts.push(format!(
"(r.user_id IN ({}) OR r.remote_actor_url IN ({}))",
local_in, remote_in
));
}
let order_clause = match sort_by {
FeedSortBy::Date => "r.watched_at DESC",
FeedSortBy::DateAsc => "r.watched_at ASC",
FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC",
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
let where_clause = where_parts.join(" AND ");
let count_sql = format!(
"SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE {}",
where_clause
);
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, r.remote_actor_url) 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
WHERE {}
ORDER BY {}
LIMIT ? OFFSET ?",
where_clause, order_clause
);
macro_rules! bind_filter_params {
($q:expr) => {{
let mut q = $q;
if has_search {
q = q.bind(search.unwrap());
}
if let Some(f) = following {
for uid in &f.local_user_ids {
q = q.bind(uid.to_string());
}
for url in &f.remote_actor_urls {
q = q.bind(url.as_str());
}
}
q
}};
}
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 rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql));
let rows = rows_q
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
let id_str = movie_id.value().to_string();
let movie = sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?
.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
FROM reviews WHERE movie_id = ? ORDER BY watched_at ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
}
async fn get_user_history(&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 = ?
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
rows.into_iter().map(DiaryRow::into_domain).collect()
}
fn stream_user_history(
&self,
user_id: UserId,
) -> BoxStream<'static, Result<DiaryEntry, DomainError>> {
let pool = self.pool.clone();
let uid = user_id.value().to_string();
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?
ORDER BY r.watched_at DESC",
)
.bind(&uid)
.fetch(&pool);
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)),
};
}
})
}
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS REAL)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = ?",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 =
sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = ?")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?;
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
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
LEFT JOIN users u ON u.id = r.user_id
WHERE r.movie_id = ?
ORDER BY r.watched_at DESC
LIMIT ? OFFSET ?",
)
.bind(&id_str)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let items = rows
.into_iter()
.map(FeedRow::into_domain)
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
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)
}
}
#[cfg(test)]
#[path = "tests/diary.rs"]
mod tests;

View File

@@ -5,6 +5,7 @@ use domain::{
models::{ models::{
AnnotatedRow, FieldMapping, ImportSession, ParsedFile, AnnotatedRow, FieldMapping, ImportSession, ParsedFile,
import::{DomainField, ImportRow, RowResult, Transform}, import::{DomainField, ImportRow, RowResult, Transform},
import_session::PersistedImportSession,
}, },
ports::ImportSessionRepository, ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
@@ -275,7 +276,7 @@ impl SqliteImportSessionRepository {
}) })
.transpose()?; .transpose()?;
Ok(ImportSession { Ok(ImportSession::from_persistence(PersistedImportSession {
id: ImportSessionId::from_uuid( id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>() id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
@@ -290,7 +291,7 @@ impl SqliteImportSessionRepository {
row_results, row_results,
created_at: Self::parse_dt(created_at)?, created_at: Self::parse_dt(created_at)?,
expires_at: Self::parse_dt(expires_at)?, expires_at: Self::parse_dt(expires_at)?,
}) }))
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{
Movie, MovieFilter, MovieSummary,
collections::{PageParams, Paginated},
},
ports::MovieRepository,
value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear},
};
use sqlx::SqlitePool;
use crate::models::{MovieRow, MovieSummaryRow};
pub struct SqliteMovieRepository {
pool: SqlitePool,
}
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()
}
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());
let title = movie.title().value();
let release_year = movie.release_year().value() as i64;
let director = movie.director();
let poster_path = movie.poster_path().map(|p| p.value().to_string());
sqlx::query(
"INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
external_metadata_id = excluded.external_metadata_id,
title = excluded.title,
release_year = excluded.release_year,
director = excluded.director,
poster_path = excluded.poster_path",
)
.bind(&id)
.bind(&external_metadata_id)
.bind(title)
.bind(release_year)
.bind(director)
.bind(&poster_path)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> {
let id = movie_id.value().to_string();
sqlx::query("DELETE FROM movies WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn existing_external_ids(
&self,
ids: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
if ids.is_empty() {
return Ok(Default::default());
}
let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
let sql = format!(
"SELECT external_metadata_id FROM movies WHERE external_metadata_id IN ({})",
placeholders.join(",")
);
let mut q = sqlx::query_scalar::<_, String>(&sql);
for id in ids {
q = q.bind(id.value().to_string());
}
let rows = q.fetch_all(&self.pool).await.map_err(Self::map_err)?;
Ok(rows.into_iter().collect())
}
async fn existing_title_year_pairs(
&self,
pairs: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
if pairs.is_empty() {
return Ok(Default::default());
}
let conditions: Vec<String> = pairs
.iter()
.map(|_| "(title = ? AND release_year = ?)".to_string())
.collect();
let sql = format!(
"SELECT DISTINCT title, release_year FROM movies WHERE {}",
conditions.join(" OR ")
);
use sqlx::Row;
let mut q = sqlx::query(&sql);
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)?;
Ok(rows
.into_iter()
.map(|r| {
let t: String = r.get("title");
let y: i64 = r.get("release_year");
(t, y as u16)
})
.collect())
}
async fn list_movies(
&self,
page: &PageParams,
filter: &MovieFilter,
) -> Result<Paginated<MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
GROUP_CONCAT(g.name) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
AND (? IS NULL OR p.original_language = ?) \
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT ? OFFSET ?",
)
.bind(&pattern)
.bind(&pattern)
.bind(language)
.bind(language)
.bind(genre)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?;
let total: i64 = sqlx::query(
"SELECT COUNT(DISTINCT m.id) \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
WHERE (? IS NULL OR LOWER(m.title) LIKE ?) \
AND (? IS NULL OR p.original_language = ?) \
AND (? IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER(?)))",
)
.bind(&pattern)
.bind(&pattern)
.bind(language)
.bind(language)
.bind(genre)
.bind(genre)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)?
.try_get(0)
.unwrap_or(0);
let items = rows
.into_iter()
.map(|r| r.into_domain())
.collect::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
}

View File

@@ -0,0 +1,106 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
models::{Review, ReviewSource},
ports::ReviewRepository,
value_objects::{ReviewId, UserId},
};
use sqlx::SqlitePool;
use crate::models::{ReviewRow, datetime_to_str};
pub struct SqliteReviewRepository {
pool: SqlitePool,
}
impl SqliteReviewRepository {
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 ReviewRepository for SqliteReviewRepository {
async fn save_review(&self, review: &Review) -> Result<DomainEvent, 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();
let rating = review.rating().value() as i64;
let comment = review.comment().map(|c| c.value().to_string());
let watched_at = datetime_to_str(review.watched_at());
let created_at = datetime_to_str(review.created_at());
let remote_actor_url = match review.source() {
ReviewSource::Local => None,
ReviewSource::Remote { actor_url } => Some(actor_url.clone()),
};
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(&id)
.bind(&movie_id)
.bind(&user_id)
.bind(rating)
.bind(&comment)
.bind(&watched_at)
.bind(&created_at)
.bind(&remote_actor_url)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
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(),
})
}
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 delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> {
let id = review_id.value().to_string();
sqlx::query("DELETE FROM reviews WHERE id = ?")
.bind(&id)
.execute(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(())
}
async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result<Vec<Review>, DomainError> {
let uid = user_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 user_id = ? ORDER BY watched_at DESC",
)
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(Self::map_err)?
.into_iter()
.map(ReviewRow::into_domain)
.collect()
}
}

View File

@@ -0,0 +1,155 @@
use async_trait::async_trait;
use domain::{
errors::DomainError,
models::{DirectorStat, MonthlyRating, UserStats, UserTrends},
ports::StatsRepository,
value_objects::UserId,
};
use sqlx::SqlitePool;
use crate::models::{DirectorCountRow, MonthlyRatingRow, UserTotalsRow};
pub struct SqliteStatsRepository {
pool: SqlitePool,
}
impl SqliteStatsRepository {
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 fn fetch_user_totals(&self, user_id: &str) -> Result<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>(
"SELECT COUNT(DISTINCT movie_id) AS total,
AVG(CAST(rating AS REAL)) AS avg_rating
FROM reviews WHERE user_id = ?",
)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(Self::map_err)
}
async fn fetch_user_favorite_director(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
let row: Option<String> = sqlx::query_scalar(
"SELECT m.director
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(row)
}
async fn fetch_user_most_active_month(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
let row: Option<String> = sqlx::query_scalar(
"SELECT strftime('%Y-%m', watched_at)
FROM reviews
WHERE user_id = ?
GROUP BY strftime('%Y-%m', watched_at)
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
Ok(row)
}
}
#[async_trait]
impl StatsRepository for SqliteStatsRepository {
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
let uid = user_id.value().to_string();
let (totals, fav_director, most_active) = tokio::try_join!(
self.fetch_user_totals(&uid),
self.fetch_user_favorite_director(&uid),
self.fetch_user_most_active_month(&uid)
)?;
let most_active_month = most_active.map(|ym| crate::format_year_month(&ym));
Ok(UserStats {
total_movies: totals.total,
avg_rating: totals.avg_rating,
favorite_director: fav_director,
most_active_month,
})
}
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 strftime('%Y-%m', watched_at) AS month,
AVG(CAST(rating AS REAL)) AS avg_rating,
COUNT(*) AS count
FROM reviews
WHERE user_id = ? AND watched_at >= datetime('now', '-12 months')
GROUP BY month
ORDER BY month ASC",
)
.bind(&uid)
.fetch_all(&self.pool),
sqlx::query_as::<_, DirectorCountRow>(
"SELECT m.director,
COUNT(*) AS count
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 5",
)
.bind(&uid)
.fetch_all(&self.pool)
)
.map_err(Self::map_err)?;
let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1);
let monthly_ratings = rating_rows
.into_iter()
.map(|r| MonthlyRating {
month_label: crate::format_year_month(&r.month),
year_month: r.month,
avg_rating: r.avg_rating,
count: r.count,
})
.collect();
let top_directors = director_rows
.into_iter()
.map(|d| DirectorStat {
director: d.director,
count: d.count,
})
.collect();
Ok(UserTrends {
monthly_ratings,
top_directors,
max_director_count,
})
}
}

View File

@@ -0,0 +1,213 @@
use super::*;
use domain::{
models::collections::PageParams,
ports::{DiaryRepository, FeedSortBy, FollowingFilter},
};
use sqlx::SqlitePool;
async fn setup(pool: &SqlitePool) {
sqlx::migrate!("./migrations").run(pool).await.unwrap();
// carol is a remote actor; we still need a non-null user_id for the schema,
// so we create a local "ghost" user and link the remote review via remote_actor_url.
sqlx::query(
"INSERT INTO users (id, email, username, password_hash, created_at) VALUES
('11111111-1111-1111-1111-111111111111', 'alice@example.com', 'alice', 'hash', '2024-01-01 00:00:00'),
('22222222-2222-2222-2222-222222222222', 'bob@example.com', 'bob', 'hash', '2024-01-01 00:00:00'),
('33333333-3333-3333-3333-333333333333', 'carol@remote.social', 'carol', 'hash', '2024-01-01 00:00:00')",
)
.execute(pool)
.await
.unwrap();
sqlx::query(
"INSERT INTO movies (id, title, release_year) VALUES
('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Inception', 2010),
('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Interstellar', 2014),
('cccccccc-cccc-cccc-cccc-cccccccccccc', 'Dune', 2021)",
)
.execute(pool)
.await
.unwrap();
// carol's review: local user_id=33333333, remote_actor_url set → remote review
sqlx::query(
"INSERT INTO reviews (id, movie_id, user_id, rating, watched_at, created_at, remote_actor_url) VALUES
('a1a1a1a1-a1a1-a1a1-a1a1-a1a1a1a1a1a1', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 5, '2024-01-01 00:00:00', '2024-01-01 00:00:00', NULL),
('b2b2b2b2-b2b2-b2b2-b2b2-b2b2b2b2b2b2', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', '22222222-2222-2222-2222-222222222222', 3, '2024-01-02 00:00:00', '2024-01-02 00:00:00', NULL),
('c3c3c3c3-c3c3-c3c3-c3c3-c3c3c3c3c3c3', 'cccccccc-cccc-cccc-cccc-cccccccccccc', '33333333-3333-3333-3333-333333333333', 4, '2024-01-03 00:00:00', '2024-01-03 00:00:00', 'https://remote.social/users/carol')",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn test_sort_by_rating_descending() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Rating, None, None)
.await
.unwrap();
let ratings: Vec<u8> = result
.items
.iter()
.map(|e| e.review().rating().value())
.collect();
assert_eq!(ratings, vec![5, 4, 3]);
}
#[tokio::test]
async fn test_search_by_title() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, Some("Dune"), None)
.await
.unwrap();
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].movie().title().value(), "Dune");
}
#[tokio::test]
async fn test_following_filter() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let filter = FollowingFilter {
local_user_ids: vec![
uuid::Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap(),
],
remote_actor_urls: vec!["https://remote.social/users/carol".to_string()],
};
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo
.query_activity_feed_filtered(&page, &FeedSortBy::Date, None, Some(&filter))
.await
.unwrap();
assert_eq!(result.items.len(), 2); // alice + carol, NOT bob
let titles: Vec<String> = result
.items
.iter()
.map(|e| e.movie().title().value().to_string())
.collect();
assert!(titles.contains(&"Inception".to_string()));
assert!(titles.contains(&"Dune".to_string()));
}
#[tokio::test]
async fn test_get_movie_stats_local() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
// Inception: 1 local review, rating=5, no federated
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 0);
assert!((stats.avg_rating.unwrap() - 5.0).abs() < 0.001);
assert_eq!(stats.rating_histogram[4], 1); // 5★ bucket
assert_eq!(stats.rating_histogram[0], 0); // 1★ bucket
}
#[tokio::test]
async fn test_get_movie_social_feed_returns_reviews_for_movie() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert_eq!(result.items[0].movie().title().value(), "Inception");
assert_eq!(result.items[0].review().rating().value(), 5);
assert_eq!(result.items[0].user_display_name(), "alice");
assert!(!result.items[0].review().is_remote());
}
#[tokio::test]
async fn test_get_movie_social_feed_federated_review() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let page = PageParams::new(Some(10), Some(0)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 1);
assert!(result.items[0].review().is_remote());
assert_eq!(
result.items[0].user_email(),
"https://remote.social/users/carol"
);
}
#[tokio::test]
async fn test_get_movie_social_feed_pagination() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
);
// offset beyond results: total_count still correct, items empty
let page = PageParams::new(Some(10), Some(5)).unwrap();
let result = repo.get_movie_social_feed(&movie_id, &page).await.unwrap();
assert_eq!(result.total_count, 1);
assert_eq!(result.items.len(), 0);
}
#[tokio::test]
async fn test_get_movie_stats_federated() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
// Dune: 1 federated review, rating=4
let movie_id = domain::value_objects::MovieId::from_uuid(
uuid::Uuid::parse_str("cccccccc-cccc-cccc-cccc-cccccccccccc").unwrap(),
);
let stats = repo.get_movie_stats(&movie_id).await.unwrap();
assert_eq!(stats.total_count, 1);
assert_eq!(stats.federated_count, 1);
assert_eq!(stats.rating_histogram[3], 1); // 4★ bucket
assert_eq!(stats.rating_histogram[4], 0); // 5★ bucket
}
#[tokio::test]
async fn count_local_posts_excludes_remote_reviews() {
let pool = SqlitePool::connect(":memory:").await.unwrap();
setup(&pool).await;
let repo = SqliteDiaryRepository::new(pool);
// setup() seeds 3 reviews: 2 local (alice, bob) + 1 remote (carol)
let count = repo.count_local_posts().await.unwrap();
assert_eq!(count, 2);
}

View File

@@ -1,6 +1,9 @@
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId, errors::DomainError,
models::UserSettings,
ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository},
value_objects::UserId,
}; };
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
@@ -23,9 +26,10 @@ impl SqliteUserSettingsRepository {
impl UserSettingsRepository for SqliteUserSettingsRepository { impl UserSettingsRepository for SqliteUserSettingsRepository {
async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> { async fn get(&self, user_id: &UserId) -> Result<UserSettings, DomainError> {
let uid = user_id.value().to_string(); let uid = user_id.value().to_string();
let row = sqlx::query(
let row = "SELECT federate_goals, federate_reviews, federate_watchlist \
sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = ?") FROM user_settings WHERE user_id = ?",
)
.bind(&uid) .bind(&uid)
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
@@ -33,10 +37,14 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
match row { match row {
Some(r) => { Some(r) => {
let federate: i64 = r.try_get("federate_goals").unwrap_or(0); let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
Ok(UserSettings::from_persistence( Ok(UserSettings::from_persistence(
user_id.clone(), user_id.clone(),
federate != 0, goals != 0,
reviews != 0,
watchlist != 0,
)) ))
} }
None => Ok(UserSettings::new(user_id.clone())), None => Ok(UserSettings::new(user_id.clone())),
@@ -45,15 +53,55 @@ impl UserSettingsRepository for SqliteUserSettingsRepository {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> {
let uid = settings.user_id().value().to_string(); let uid = settings.user_id().value().to_string();
let federate = if settings.federate_goals() { 1i64 } else { 0 }; sqlx::query(
"INSERT OR REPLACE INTO user_settings \
sqlx::query("INSERT OR REPLACE INTO user_settings (user_id, federate_goals) VALUES (?, ?)") (user_id, federate_goals, federate_reviews, federate_watchlist) \
VALUES (?, ?, ?, ?)",
)
.bind(&uid) .bind(&uid)
.bind(federate) .bind(if settings.federate_goals() { 1i64 } else { 0 })
.bind(if settings.federate_reviews() { 1i64 } else { 0 })
.bind(if settings.federate_watchlist() {
1i64
} else {
0
})
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
Ok(()) Ok(())
} }
} }
#[async_trait]
impl UserFederationSettingsQuery for SqliteUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = ?",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(Self::map_err)?;
match row {
Some(r) => {
let goals: i64 = r.try_get("federate_goals").unwrap_or(1);
let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1);
let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1);
Ok(FederationFlags {
goals: goals != 0,
reviews: reviews != 0,
watchlist: watchlist != 0,
})
}
None => Ok(FederationFlags {
goals: true,
reviews: true,
watchlist: true,
}),
}
}
}

View File

@@ -1,6 +1,8 @@
use std::sync::Arc; use std::sync::Arc;
use application::movies::{commands::EnrichMovieCommand, enrich_movie, request_enrichment}; use application::movies::{
commands::EnrichMovieCommand, deps::EnrichMovieDeps, enrich_movie, request_enrichment,
};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
@@ -81,7 +83,7 @@ impl EventHandler for MovieEnrichmentHandler {
self.enrichment_client.as_ref(), self.enrichment_client.as_ref(),
&self.profile_repo, &self.profile_repo,
movie_id.clone(), movie_id.clone(),
&external_metadata_id, external_metadata_id.value(),
) )
.await? .await?
else { else {
@@ -89,13 +91,12 @@ impl EventHandler for MovieEnrichmentHandler {
}; };
self.download_cast_photos(&profile).await; self.download_cast_photos(&profile).await;
enrich_movie::execute( let enrich_deps = EnrichMovieDeps {
&self.movie_repository, movie: self.movie_repository.clone(),
&self.profile_repo, movie_profile: self.profile_repo.clone(),
&self.person_command, person_command: self.person_command.clone(),
&self.search_command, search_command: self.search_command.clone(),
EnrichMovieCommand { movie_id, profile }, };
) enrich_movie::execute(&enrich_deps, EnrichMovieCommand { movie_id, profile }).await
.await
} }
} }

View File

@@ -1,15 +1,31 @@
use async_trait::async_trait; use std::sync::Arc;
use domain::{errors::DomainError, events::DomainEvent, ports::EventHandler};
use application::context::AppContext; use async_trait::async_trait;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventHandler, PersonCommand, PersonEnrichmentClient, PersonQuery},
};
use application::person::deps::EnrichPersonDeps;
pub struct PersonEnrichmentHandler { pub struct PersonEnrichmentHandler {
ctx: AppContext, deps: EnrichPersonDeps,
} }
impl PersonEnrichmentHandler { impl PersonEnrichmentHandler {
pub fn new(ctx: AppContext) -> Self { pub fn new(
Self { ctx } person_query: Arc<dyn PersonQuery>,
person_enrichment: Option<Arc<dyn PersonEnrichmentClient>>,
person_command: Arc<dyn PersonCommand>,
) -> Self {
Self {
deps: EnrichPersonDeps {
person_query,
person_enrichment,
person_command,
},
}
} }
} }
@@ -24,6 +40,7 @@ impl EventHandler for PersonEnrichmentHandler {
_ => return Ok(()), _ => return Ok(()),
}; };
application::person::enrich::execute(&self.ctx, person_id, &external_person_id).await application::person::enrich::execute(&self.deps, person_id, external_person_id.value())
.await
} }
} }

View File

@@ -29,9 +29,13 @@ pub struct UpdateGoalRequest {
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct UserSettingsDto { pub struct UserSettingsDto {
pub federate_goals: bool, pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
} }
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct UpdateUserSettingsRequest { pub struct UpdateUserSettingsRequest {
pub federate_goals: bool, pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
} }

View File

@@ -15,6 +15,7 @@ sha2 = { workspace = true }
rand = { workspace = true } rand = { workspace = true }
hex = { workspace = true } hex = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
bytes = { workspace = true }
[features] [features]
xlsx = [] xlsx = []

View File

@@ -0,0 +1,33 @@
use std::sync::Arc;
use domain::ports::{AuthService, PasswordHasher, RefreshSessionRepository, UserRepository};
use crate::config::AppConfig;
pub struct LoginDeps {
pub user: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub auth: Arc<dyn AuthService>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub config: AppConfig,
}
pub struct RegisterDeps {
pub user: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub config: AppConfig,
}
pub struct RefreshDeps {
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub auth: Arc<dyn AuthService>,
pub config: AppConfig,
}
pub struct RegisterAndLoginDeps {
pub user: Arc<dyn UserRepository>,
pub password_hasher: Arc<dyn PasswordHasher>,
pub auth: Arc<dyn AuthService>,
pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub config: AppConfig,
}

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, models::RefreshSession, value_objects::Email}; use domain::{errors::DomainError, models::RefreshSession, value_objects::Email};
use crate::{auth::queries::LoginQuery, context::AppContext}; use crate::auth::{deps::LoginDeps, queries::LoginQuery};
pub struct LoginResult { pub struct LoginResult {
pub token: String, pub token: String,
@@ -14,17 +14,15 @@ pub struct LoginResult {
pub role: String, pub role: String,
} }
pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult, DomainError> { pub async fn execute(deps: &LoginDeps, query: LoginQuery) -> Result<LoginResult, DomainError> {
let email = Email::new(query.email)?; let email = Email::new(query.email)?;
let user = ctx let user = deps
.repos
.user .user
.find_by_email(&email) .find_by_email(&email)
.await? .await?
.ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?; .ok_or_else(|| DomainError::Unauthorized("Invalid credentials".into()))?;
let valid = ctx let valid = deps
.services
.password_hasher .password_hasher
.verify(&query.password, user.password_hash()) .verify(&query.password, user.password_hash())
.await?; .await?;
@@ -32,10 +30,10 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
return Err(DomainError::Unauthorized("Invalid credentials".into())); return Err(DomainError::Unauthorized("Invalid credentials".into()));
} }
let generated = ctx.services.auth.generate_token(user.id()).await?; let generated = deps.auth.generate_token(user.id()).await?;
let refresh_token = Uuid::new_v4().to_string(); let refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64); let refresh_expires = Utc::now() + Duration::seconds(deps.config.refresh_ttl_seconds as i64);
let session = RefreshSession { let session = RefreshSession {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: user.id().clone(), user_id: user.id().clone(),
@@ -43,7 +41,7 @@ pub async fn execute(ctx: &AppContext, query: LoginQuery) -> Result<LoginResult,
expires_at: refresh_expires, expires_at: refresh_expires,
created_at: Utc::now(), created_at: Utc::now(),
}; };
ctx.repos.refresh_session.create(&session).await?; deps.refresh_session.create(&session).await?;
Ok(LoginResult { Ok(LoginResult {
token: generated.token, token: generated.token,

View File

@@ -1,9 +1,12 @@
use domain::errors::DomainError; use std::sync::Arc;
use crate::context::AppContext; use domain::{errors::DomainError, ports::RefreshSessionRepository};
pub async fn execute(ctx: &AppContext, refresh_token: &str) -> Result<(), DomainError> { pub async fn execute(
ctx.repos.refresh_session.revoke(refresh_token).await refresh_session: Arc<dyn RefreshSessionRepository>,
refresh_token: &str,
) -> Result<(), DomainError> {
refresh_session.revoke(refresh_token).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,4 +1,5 @@
pub mod commands; pub mod commands;
pub mod deps;
pub mod login; pub mod login;
pub mod logout; pub mod logout;
pub mod queries; pub mod queries;

View File

@@ -3,7 +3,7 @@ use uuid::Uuid;
use domain::{errors::DomainError, models::RefreshSession}; use domain::{errors::DomainError, models::RefreshSession};
use crate::context::AppContext; use crate::auth::deps::RefreshDeps;
pub struct RefreshResult { pub struct RefreshResult {
pub token: String, pub token: String,
@@ -12,30 +12,29 @@ pub struct RefreshResult {
} }
pub async fn execute( pub async fn execute(
ctx: &AppContext, deps: &RefreshDeps,
old_refresh_token: &str, old_refresh_token: &str,
) -> Result<RefreshResult, DomainError> { ) -> Result<RefreshResult, DomainError> {
let session = ctx let session = deps
.repos
.refresh_session .refresh_session
.get_by_token(old_refresh_token) .get_by_token(old_refresh_token)
.await? .await?
.ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?; .ok_or_else(|| DomainError::Unauthorized("Invalid refresh token".into()))?;
if session.expires_at < Utc::now() { if session.expires_at < Utc::now() {
ctx.repos.refresh_session.revoke(old_refresh_token).await?; deps.refresh_session.revoke(old_refresh_token).await?;
return Err(DomainError::Unauthorized("Refresh token expired".into())); return Err(DomainError::Unauthorized("Refresh token expired".into()));
} }
// Revoke old token (rotation) // Revoke old token (rotation)
ctx.repos.refresh_session.revoke(old_refresh_token).await?; deps.refresh_session.revoke(old_refresh_token).await?;
// Generate new access token // Generate new access token
let generated = ctx.services.auth.generate_token(&session.user_id).await?; let generated = deps.auth.generate_token(&session.user_id).await?;
// Create new refresh session // Create new refresh session
let new_refresh_token = Uuid::new_v4().to_string(); let new_refresh_token = Uuid::new_v4().to_string();
let refresh_expires = Utc::now() + Duration::seconds(ctx.config.refresh_ttl_seconds as i64); let refresh_expires = Utc::now() + Duration::seconds(deps.config.refresh_ttl_seconds as i64);
let new_session = RefreshSession { let new_session = RefreshSession {
id: Uuid::new_v4(), id: Uuid::new_v4(),
user_id: session.user_id, user_id: session.user_id,
@@ -43,7 +42,7 @@ pub async fn execute(
expires_at: refresh_expires, expires_at: refresh_expires,
created_at: Utc::now(), created_at: Utc::now(),
}; };
ctx.repos.refresh_session.create(&new_session).await?; deps.refresh_session.create(&new_session).await?;
Ok(RefreshResult { Ok(RefreshResult {
token: generated.token, token: generated.token,

View File

@@ -4,10 +4,10 @@ use domain::{
value_objects::{Email, Password, Username}, value_objects::{Email, Password, Username},
}; };
use crate::{auth::commands::RegisterCommand, context::AppContext}; use crate::auth::{commands::RegisterCommand, deps::RegisterDeps};
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), DomainError> { pub async fn execute(deps: &RegisterDeps, cmd: RegisterCommand) -> Result<(), DomainError> {
if !ctx.config.allow_registration { if !deps.config.allow_registration {
return Err(DomainError::Unauthorized("Registration is disabled".into())); return Err(DomainError::Unauthorized("Registration is disabled".into()));
} }
@@ -15,21 +15,20 @@ pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> Result<(), Domai
let email = Email::new(cmd.email)?; let email = Email::new(cmd.email)?;
let username = Username::new(cmd.username)?; let username = Username::new(cmd.username)?;
if ctx.repos.user.find_by_email(&email).await?.is_some() { if deps.user.find_by_email(&email).await?.is_some() {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Email already registered".into(), "Email already registered".into(),
)); ));
} }
if ctx.repos.user.find_by_username(&username).await?.is_some() { if deps.user.find_by_username(&username).await?.is_some() {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Username already taken".into(), "Username already taken".into(),
)); ));
} }
let hash = ctx.services.password_hasher.hash(password.value()).await?; let hash = deps.password_hasher.hash(password.value()).await?;
ctx.repos deps.user
.user
.save(&User::new(email, username, hash, cmd.role)) .save(&User::new(email, username, hash, cmd.role))
.await .await
} }

View File

@@ -1,18 +1,25 @@
use domain::errors::DomainError; use domain::errors::DomainError;
use crate::{ use crate::auth::{
auth::commands::RegisterAndLoginCommand, commands::{RegisterAndLoginCommand, RegisterCommand},
auth::{login, register}, deps::{LoginDeps, RegisterAndLoginDeps, RegisterDeps},
context::AppContext, login::{self, LoginResult},
queries::LoginQuery,
register,
}; };
pub async fn execute( pub async fn execute(
ctx: &AppContext, deps: &RegisterAndLoginDeps,
cmd: RegisterAndLoginCommand, cmd: RegisterAndLoginCommand,
) -> Result<login::LoginResult, DomainError> { ) -> Result<LoginResult, DomainError> {
let reg_deps = RegisterDeps {
user: deps.user.clone(),
password_hasher: deps.password_hasher.clone(),
config: deps.config.clone(),
};
register::execute( register::execute(
ctx, &reg_deps,
crate::auth::commands::RegisterCommand { RegisterCommand {
email: cmd.email.clone(), email: cmd.email.clone(),
username: cmd.username, username: cmd.username,
password: cmd.password.clone(), password: cmd.password.clone(),
@@ -21,9 +28,16 @@ pub async fn execute(
) )
.await?; .await?;
let log_deps = LoginDeps {
user: deps.user.clone(),
password_hasher: deps.password_hasher.clone(),
auth: deps.auth.clone(),
refresh_session: deps.refresh_session.clone(),
config: deps.config.clone(),
};
login::execute( login::execute(
ctx, &log_deps,
crate::auth::queries::LoginQuery { LoginQuery {
email: cmd.email, email: cmd.email,
password: cmd.password, password: cmd.password,
}, },

View File

@@ -4,15 +4,24 @@ use domain::models::UserRole;
use domain::testing::InMemoryUserRepository; use domain::testing::InMemoryUserRepository;
use crate::{ use crate::{
auth::commands::RegisterCommand, auth::{
auth::queries::LoginQuery, commands::RegisterCommand,
auth::{login, register}, deps::{LoginDeps, RegisterDeps},
login,
queries::LoginQuery,
register,
},
test_helpers::TestContextBuilder, test_helpers::TestContextBuilder,
}; };
async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &str) { async fn setup_user(b: &TestContextBuilder, email: &str, password: &str) {
let deps = RegisterDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute( register::execute(
ctx, &deps,
RegisterCommand { RegisterCommand {
email: email.to_string(), email: email.to_string(),
username: "testuser".to_string(), username: "testuser".to_string(),
@@ -27,14 +36,18 @@ async fn setup_user(ctx: &crate::context::AppContext, email: &str, password: &st
#[tokio::test] #[tokio::test]
async fn test_login_valid_credentials_returns_token() { async fn test_login_valid_credentials_returns_token() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) setup_user(&b, "carol@example.com", "secret123").await;
.build();
setup_user(&ctx, "carol@example.com", "secret123").await;
let deps = LoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
let result = login::execute( let result = login::execute(
&ctx, &deps,
LoginQuery { LoginQuery {
email: "carol@example.com".into(), email: "carol@example.com".into(),
password: "secret123".into(), password: "secret123".into(),
@@ -51,14 +64,18 @@ async fn test_login_valid_credentials_returns_token() {
#[tokio::test] #[tokio::test]
async fn test_login_wrong_password_fails() { async fn test_login_wrong_password_fails() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) setup_user(&b, "dave@example.com", "correct_password").await;
.build();
setup_user(&ctx, "dave@example.com", "correct_password").await;
let deps = LoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
let result = login::execute( let result = login::execute(
&ctx, &deps,
LoginQuery { LoginQuery {
email: "dave@example.com".into(), email: "dave@example.com".into(),
password: "wrong_password".into(), password: "wrong_password".into(),
@@ -71,10 +88,16 @@ async fn test_login_wrong_password_fails() {
#[tokio::test] #[tokio::test]
async fn test_login_unknown_email_fails() { async fn test_login_unknown_email_fails() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let deps = LoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
let result = login::execute( let result = login::execute(
&ctx, &deps,
LoginQuery { LoginQuery {
email: "nobody@example.com".into(), email: "nobody@example.com".into(),
password: "anything".into(), password: "anything".into(),

View File

@@ -4,21 +4,28 @@ use domain::models::UserRole;
use domain::testing::InMemoryUserRepository; use domain::testing::InMemoryUserRepository;
use crate::{ use crate::{
auth::commands::RegisterCommand, auth::{
auth::queries::LoginQuery, commands::RegisterCommand,
auth::{login, logout, refresh, register}, deps::{LoginDeps, RefreshDeps, RegisterDeps},
login, logout,
queries::LoginQuery,
refresh, register,
},
test_helpers::TestContextBuilder, test_helpers::TestContextBuilder,
}; };
#[tokio::test] #[tokio::test]
async fn logout_revokes_refresh_token() { async fn logout_revokes_refresh_token() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _)
.build();
let reg_deps = RegisterDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute( register::execute(
&ctx, &reg_deps,
RegisterCommand { RegisterCommand {
email: "bob@example.com".to_string(), email: "bob@example.com".to_string(),
username: "bob".to_string(), username: "bob".to_string(),
@@ -29,8 +36,15 @@ async fn logout_revokes_refresh_token() {
.await .await
.unwrap(); .unwrap();
let login_deps = LoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
let login_result = login::execute( let login_result = login::execute(
&ctx, &login_deps,
LoginQuery { LoginQuery {
email: "bob@example.com".into(), email: "bob@example.com".into(),
password: "password123".into(), password: "password123".into(),
@@ -39,17 +53,22 @@ async fn logout_revokes_refresh_token() {
.await .await
.unwrap(); .unwrap();
logout::execute(&ctx, &login_result.refresh_token) logout::execute(b.refresh_session_repo.clone(), &login_result.refresh_token)
.await .await
.unwrap(); .unwrap();
let refresh_attempt = refresh::execute(&ctx, &login_result.refresh_token).await; let refresh_deps = RefreshDeps {
refresh_session: b.refresh_session_repo.clone(),
auth: b.auth_service.clone(),
config: b.config.clone(),
};
let refresh_attempt = refresh::execute(&refresh_deps, &login_result.refresh_token).await;
assert!(refresh_attempt.is_err()); assert!(refresh_attempt.is_err());
} }
#[tokio::test] #[tokio::test]
async fn logout_with_unknown_token_succeeds() { async fn logout_with_unknown_token_succeeds() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = logout::execute(&ctx, "nonexistent-token").await; let result = logout::execute(b.refresh_session_repo.clone(), "nonexistent-token").await;
assert!(result.is_ok()); assert!(result.is_ok());
} }

View File

@@ -4,15 +4,24 @@ use domain::models::UserRole;
use domain::testing::InMemoryUserRepository; use domain::testing::InMemoryUserRepository;
use crate::{ use crate::{
auth::commands::RegisterCommand, auth::{
auth::queries::LoginQuery, commands::RegisterCommand,
auth::{login, refresh, register}, deps::{LoginDeps, RefreshDeps, RegisterDeps},
login,
queries::LoginQuery,
refresh, register,
},
test_helpers::TestContextBuilder, test_helpers::TestContextBuilder,
}; };
async fn login_user(ctx: &crate::context::AppContext) -> login::LoginResult { async fn login_user(b: &TestContextBuilder) -> login::LoginResult {
let reg_deps = RegisterDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute( register::execute(
ctx, &reg_deps,
RegisterCommand { RegisterCommand {
email: "alice@example.com".to_string(), email: "alice@example.com".to_string(),
username: "alice".to_string(), username: "alice".to_string(),
@@ -23,8 +32,15 @@ async fn login_user(ctx: &crate::context::AppContext) -> login::LoginResult {
.await .await
.unwrap(); .unwrap();
let login_deps = LoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
login::execute( login::execute(
ctx, &login_deps,
LoginQuery { LoginQuery {
email: "alice@example.com".into(), email: "alice@example.com".into(),
password: "password123".into(), password: "password123".into(),
@@ -37,13 +53,15 @@ async fn login_user(ctx: &crate::context::AppContext) -> login::LoginResult {
#[tokio::test] #[tokio::test]
async fn refresh_returns_new_tokens() { async fn refresh_returns_new_tokens() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) let login_result = login_user(&b).await;
.build();
let login_result = login_user(&ctx).await; let deps = RefreshDeps {
refresh_session: b.refresh_session_repo.clone(),
let result = refresh::execute(&ctx, &login_result.refresh_token) auth: b.auth_service.clone(),
config: b.config.clone(),
};
let result = refresh::execute(&deps, &login_result.refresh_token)
.await .await
.unwrap(); .unwrap();
@@ -55,33 +73,37 @@ async fn refresh_returns_new_tokens() {
#[tokio::test] #[tokio::test]
async fn refresh_rotates_token_old_one_invalid() { async fn refresh_rotates_token_old_one_invalid() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) let login_result = login_user(&b).await;
.build();
let login_result = login_user(&ctx).await;
let old_token = login_result.refresh_token.clone(); let old_token = login_result.refresh_token.clone();
refresh::execute(&ctx, &old_token).await.unwrap(); let deps = RefreshDeps {
refresh_session: b.refresh_session_repo.clone(),
auth: b.auth_service.clone(),
config: b.config.clone(),
};
refresh::execute(&deps, &old_token).await.unwrap();
let retry = refresh::execute(&ctx, &old_token).await; let retry = refresh::execute(&deps, &old_token).await;
assert!(retry.is_err()); assert!(retry.is_err());
} }
#[tokio::test] #[tokio::test]
async fn refresh_with_new_token_works() { async fn refresh_with_new_token_works() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) let login_result = login_user(&b).await;
.build();
let login_result = login_user(&ctx).await; let deps = RefreshDeps {
refresh_session: b.refresh_session_repo.clone(),
let first = refresh::execute(&ctx, &login_result.refresh_token) auth: b.auth_service.clone(),
config: b.config.clone(),
};
let first = refresh::execute(&deps, &login_result.refresh_token)
.await .await
.unwrap(); .unwrap();
let second = refresh::execute(&ctx, &first.refresh_token).await.unwrap(); let second = refresh::execute(&deps, &first.refresh_token).await.unwrap();
assert!(!second.token.is_empty()); assert!(!second.token.is_empty());
assert_ne!(second.refresh_token, first.refresh_token); assert_ne!(second.refresh_token, first.refresh_token);
@@ -89,8 +111,12 @@ async fn refresh_with_new_token_works() {
#[tokio::test] #[tokio::test]
async fn refresh_with_unknown_token_fails() { async fn refresh_with_unknown_token_fails() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let deps = RefreshDeps {
let result = refresh::execute(&ctx, "nonexistent-token").await; refresh_session: b.refresh_session_repo.clone(),
auth: b.auth_service.clone(),
config: b.config.clone(),
};
let result = refresh::execute(&deps, "nonexistent-token").await;
assert!(result.is_err()); assert!(result.is_err());
} }

View File

@@ -5,7 +5,10 @@ use domain::ports::UserRepository;
use domain::testing::InMemoryUserRepository; use domain::testing::InMemoryUserRepository;
use domain::value_objects::Email; use domain::value_objects::Email;
use crate::{auth::commands::RegisterCommand, auth::register, test_helpers::TestContextBuilder}; use crate::{
auth::{commands::RegisterCommand, deps::RegisterDeps, register},
test_helpers::TestContextBuilder,
};
fn cmd(email: &str) -> RegisterCommand { fn cmd(email: &str) -> RegisterCommand {
RegisterCommand { RegisterCommand {
@@ -19,11 +22,14 @@ fn cmd(email: &str) -> RegisterCommand {
#[tokio::test] #[tokio::test]
async fn test_register_creates_user() { async fn test_register_creates_user() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) let deps = RegisterDeps {
.build(); user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute(&ctx, cmd("alice@example.com")) register::execute(&deps, cmd("alice@example.com"))
.await .await
.unwrap(); .unwrap();
@@ -36,22 +42,30 @@ async fn test_register_creates_user() {
#[tokio::test] #[tokio::test]
async fn test_register_duplicate_email_fails() { async fn test_register_duplicate_email_fails() {
let users = InMemoryUserRepository::new(); let users = InMemoryUserRepository::new();
let ctx = TestContextBuilder::new() let b = TestContextBuilder::new().with_users(Arc::clone(&users) as _);
.with_users(Arc::clone(&users) as _) let deps = RegisterDeps {
.build(); user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
register::execute(&ctx, cmd("bob@example.com")) register::execute(&deps, cmd("bob@example.com"))
.await .await
.unwrap(); .unwrap();
let result = register::execute(&ctx, cmd("bob@example.com")).await; let result = register::execute(&deps, cmd("bob@example.com")).await;
assert!(result.is_err(), "duplicate email should fail"); assert!(result.is_err(), "duplicate email should fail");
} }
#[tokio::test] #[tokio::test]
async fn test_register_short_password_fails() { async fn test_register_short_password_fails() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let deps = RegisterDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
config: b.config.clone(),
};
let result = register::execute( let result = register::execute(
&ctx, &deps,
RegisterCommand { RegisterCommand {
email: "x@y.com".to_string(), email: "x@y.com".to_string(),
username: "testuser".to_string(), username: "testuser".to_string(),

View File

@@ -1,13 +1,21 @@
use crate::auth::commands::RegisterAndLoginCommand; use crate::auth::commands::RegisterAndLoginCommand;
use crate::auth::deps::RegisterAndLoginDeps;
use crate::auth::register_and_login; use crate::auth::register_and_login;
use crate::test_helpers::TestContextBuilder; use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn registers_and_returns_token() { async fn registers_and_returns_token() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let deps = RegisterAndLoginDeps {
user: b.user_repo.clone(),
password_hasher: b.password_hasher.clone(),
auth: b.auth_service.clone(),
refresh_session: b.refresh_session_repo.clone(),
config: b.config.clone(),
};
let result = register_and_login::execute( let result = register_and_login::execute(
&ctx, &deps,
RegisterAndLoginCommand { RegisterAndLoginCommand {
email: "new@example.com".into(), email: "new@example.com".into(),
username: "newuser".into(), username: "newuser".into(),

View File

@@ -1,16 +1,15 @@
use crate::{context::AppContext, diary::commands::DeleteReviewCommand}; use crate::diary::{commands::DeleteReviewCommand, deps::DeleteReviewDeps};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
value_objects::{ReviewId, UserId}, value_objects::{ReviewId, UserId},
}; };
pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), DomainError> { pub async fn execute(deps: &DeleteReviewDeps, cmd: DeleteReviewCommand) -> Result<(), DomainError> {
let review_id = ReviewId::from_uuid(cmd.review_id); let review_id = ReviewId::from_uuid(cmd.review_id);
let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id); let requesting_user_id = UserId::from_uuid(cmd.requesting_user_id);
let review = ctx let review = deps
.repos
.review .review
.get_review_by_id(&review_id) .get_review_by_id(&review_id)
.await? .await?
@@ -21,10 +20,9 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
} }
let movie_id = review.movie_id().clone(); let movie_id = review.movie_id().clone();
ctx.repos.review.delete_review(&review_id).await?; deps.review.delete_review(&review_id).await?;
if let Err(e) = ctx if let Err(e) = deps
.services
.event_publisher .event_publisher
.publish(&DomainEvent::ReviewDeleted { .publish(&DomainEvent::ReviewDeleted {
review_id: review_id.clone(), review_id: review_id.clone(),
@@ -35,13 +33,12 @@ pub async fn execute(ctx: &AppContext, cmd: DeleteReviewCommand) -> Result<(), D
tracing::warn!("failed to publish ReviewDeleted: {e}"); tracing::warn!("failed to publish ReviewDeleted: {e}");
} }
let history = ctx.repos.diary.get_review_history(&movie_id).await?; let history = deps.diary.get_review_history(&movie_id).await?;
if history.viewings().is_empty() { if history.viewings().is_empty() {
let poster_path = history.movie().poster_path().cloned(); let poster_path = history.movie().poster_path().cloned();
ctx.repos.movie.delete_movie(&movie_id).await?; deps.movie.delete_movie(&movie_id).await?;
// best-effort: movie is already deleted, so publish failure is non-fatal // best-effort: movie is already deleted, so publish failure is non-fatal
if let Err(e) = ctx if let Err(e) = deps
.services
.event_publisher .event_publisher
.publish(&DomainEvent::MovieDeleted { .publish(&DomainEvent::MovieDeleted {
movie_id, movie_id,

View File

@@ -0,0 +1,27 @@
use std::sync::Arc;
use domain::ports::{
DiaryRepository, EventPublisher, MovieProfileRepository, MovieRepository, ReviewRepository,
SocialQueryPort,
};
use crate::config::AppConfig;
pub struct DeleteReviewDeps {
pub review: Arc<dyn ReviewRepository>,
pub diary: Arc<dyn DiaryRepository>,
pub movie: Arc<dyn MovieRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}
pub struct GetMovieSocialPageDeps {
pub movie: Arc<dyn MovieRepository>,
pub diary: Arc<dyn DiaryRepository>,
pub movie_profile: Arc<dyn MovieProfileRepository>,
}
pub struct GetActivityFeedDeps {
pub diary: Arc<dyn DiaryRepository>,
pub social_query: Arc<dyn SocialQueryPort>,
pub config: AppConfig,
}

View File

@@ -1,15 +1,21 @@
use domain::{errors::DomainError, value_objects::UserId}; use std::sync::Arc;
use crate::{context::AppContext, diary::queries::ExportQuery}; use bytes::Bytes;
use domain::{
errors::DomainError,
ports::{DiaryExporter, DiaryRepository},
value_objects::UserId,
};
use futures::stream::BoxStream;
pub async fn execute(ctx: &AppContext, query: ExportQuery) -> Result<Vec<u8>, DomainError> { use crate::diary::queries::ExportQuery;
let entries = ctx
.repos pub fn execute(
.diary diary: &Arc<dyn DiaryRepository>,
.get_user_history(&UserId::from_uuid(query.user_id)) diary_exporter: &Arc<dyn DiaryExporter>,
.await?; query: ExportQuery,
ctx.services ) -> BoxStream<'static, Result<Bytes, DomainError>> {
.diary_exporter let user_id = UserId::from_uuid(query.user_id);
.serialize_entries(&entries, query.format) let entry_stream = diary.stream_user_history(user_id);
.await diary_exporter.stream_entries(entry_stream, query.format)
} }

View File

@@ -1,4 +1,4 @@
use crate::{context::AppContext, diary::queries::GetActivityFeedQuery}; use crate::diary::{deps::GetActivityFeedDeps, queries::GetActivityFeedQuery};
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
@@ -9,15 +9,14 @@ use domain::{
}; };
pub async fn execute( pub async fn execute(
ctx: &AppContext, deps: &GetActivityFeedDeps,
query: GetActivityFeedQuery, query: GetActivityFeedQuery,
) -> Result<Paginated<FeedEntry>, DomainError> { ) -> Result<Paginated<FeedEntry>, DomainError> {
let page = PageParams::new(Some(query.limit), Some(query.offset))?; let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let following = build_following_filter(ctx, &query).await; let following = build_following_filter(deps, &query).await;
ctx.repos deps.diary
.diary
.query_activity_feed_filtered( .query_activity_feed_filtered(
&page, &page,
&query.sort_by, &query.sort_by,
@@ -28,15 +27,14 @@ pub async fn execute(
} }
async fn build_following_filter( async fn build_following_filter(
ctx: &AppContext, deps: &GetActivityFeedDeps,
query: &GetActivityFeedQuery, query: &GetActivityFeedQuery,
) -> Option<FollowingFilter> { ) -> Option<FollowingFilter> {
if !query.filter_following { if !query.filter_following {
return None; return None;
} }
let viewer_id = query.viewer_user_id?; let viewer_id = query.viewer_user_id?;
let urls = ctx let urls = deps
.repos
.social_query .social_query
.get_accepted_following_urls(viewer_id) .get_accepted_following_urls(viewer_id)
.await .await
@@ -47,7 +45,7 @@ async fn build_following_filter(
remote_actor_urls: vec![], remote_actor_urls: vec![],
}); });
} }
let base_url = &ctx.config.base_url; let base_url = &deps.config.base_url;
let mut local_ids = vec![viewer_id]; let mut local_ids = vec![viewer_id];
let mut remote_urls = Vec::new(); let mut remote_urls = Vec::new();
for url in urls { for url in urls {

View File

@@ -1,16 +1,19 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ models::{
DiaryEntry, DiaryFilter, SortDirection, DiaryEntry, DiaryFilter, SortDirection,
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::DiaryRepository,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use crate::{context::AppContext, diary::queries::GetDiaryQuery}; use crate::diary::queries::GetDiaryQuery;
pub async fn execute( pub async fn execute(
ctx: &AppContext, diary: &Arc<dyn DiaryRepository>,
query: GetDiaryQuery, query: GetDiaryQuery,
) -> Result<Paginated<DiaryEntry>, DomainError> { ) -> Result<Paginated<DiaryEntry>, DomainError> {
let page = PageParams::new(query.limit, query.offset)?; let page = PageParams::new(query.limit, query.offset)?;
@@ -25,7 +28,7 @@ pub async fn execute(
search: None, search: None,
}; };
ctx.repos.diary.query_diary(&filter).await diary.query_diary(&filter).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -7,7 +7,7 @@ use domain::{
value_objects::MovieId, value_objects::MovieId,
}; };
use crate::{context::AppContext, diary::queries::GetMovieSocialPageQuery}; use crate::diary::{deps::GetMovieSocialPageDeps, queries::GetMovieSocialPageQuery};
pub struct MovieSocialPageResult { pub struct MovieSocialPageResult {
pub movie: Movie, pub movie: Movie,
@@ -17,23 +17,22 @@ pub struct MovieSocialPageResult {
} }
pub async fn execute( pub async fn execute(
ctx: &AppContext, deps: &GetMovieSocialPageDeps,
query: GetMovieSocialPageQuery, query: GetMovieSocialPageQuery,
) -> Result<MovieSocialPageResult, DomainError> { ) -> Result<MovieSocialPageResult, DomainError> {
let movie_id = MovieId::from_uuid(query.movie_id); let movie_id = MovieId::from_uuid(query.movie_id);
let page = PageParams::new(Some(query.limit), Some(query.offset))?; let page = PageParams::new(Some(query.limit), Some(query.offset))?;
let movie = ctx let movie = deps
.repos
.movie .movie
.get_movie_by_id(&movie_id) .get_movie_by_id(&movie_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?; .ok_or_else(|| DomainError::NotFound(format!("Movie {}", query.movie_id)))?;
let (stats, reviews, profile) = tokio::try_join!( let (stats, reviews, profile) = tokio::try_join!(
ctx.repos.diary.get_movie_stats(&movie_id), deps.diary.get_movie_stats(&movie_id),
ctx.repos.diary.get_movie_social_feed(&movie_id, &page), deps.diary.get_movie_social_feed(&movie_id, &page),
ctx.repos.movie_profile.get_by_movie_id(&movie_id), deps.movie_profile.get_by_movie_id(&movie_id),
)?; )?;
Ok(MovieSocialPageResult { Ok(MovieSocialPageResult {

View File

@@ -1,19 +1,22 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ReviewHistory, models::ReviewHistory,
ports::DiaryRepository,
services::review_history::{ReviewHistoryAnalyzer, Trend}, services::review_history::{ReviewHistoryAnalyzer, Trend},
value_objects::MovieId, value_objects::MovieId,
}; };
use crate::{context::AppContext, diary::queries::GetReviewHistoryQuery}; use crate::diary::queries::GetReviewHistoryQuery;
pub async fn execute( pub async fn execute(
ctx: &AppContext, diary: &Arc<dyn DiaryRepository>,
query: GetReviewHistoryQuery, query: GetReviewHistoryQuery,
) -> Result<(ReviewHistory, Trend), DomainError> { ) -> Result<(ReviewHistory, Trend), DomainError> {
let movie_id = MovieId::from_uuid(query.movie_id); let movie_id = MovieId::from_uuid(query.movie_id);
let mut history = ctx.repos.diary.get_review_history(&movie_id).await?; let mut history = diary.get_review_history(&movie_id).await?;
let trend = ReviewHistoryAnalyzer::rating_trend(&history)?; let trend = ReviewHistoryAnalyzer::rating_trend(&history)?;

View File

@@ -1,9 +1,14 @@
use std::sync::Arc;
use domain::errors::DomainError; use domain::errors::DomainError;
use crate::{context::AppContext, diary::commands::LogReviewCommand}; use crate::{diary::commands::LogReviewCommand, ports::ReviewLogger};
pub async fn execute(ctx: &AppContext, cmd: LogReviewCommand) -> Result<(), DomainError> { pub async fn execute(
ctx.services.review_logger.log_review(cmd).await review_logger: &Arc<dyn ReviewLogger>,
cmd: LogReviewCommand,
) -> Result<(), DomainError> {
review_logger.log_review(cmd).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,5 +1,6 @@
pub mod commands; pub mod commands;
pub mod delete_review; pub mod delete_review;
pub mod deps;
pub mod export_diary; pub mod export_diary;
pub mod get_activity_feed; pub mod get_activity_feed;
pub mod get_diary; pub mod get_diary;

View File

@@ -112,7 +112,7 @@ async fn publish_events(
publisher publisher
.publish(&DomainEvent::MovieEnrichmentRequested { .publish(&DomainEvent::MovieEnrichmentRequested {
movie_id: movie.id().clone(), movie_id: movie.id().clone(),
external_metadata_id: ext_id.value().to_string(), external_metadata_id: ext_id.clone(),
}) })
.await?; .await?;
} }

View File

@@ -12,7 +12,7 @@ use domain::{
}; };
use crate::{ use crate::{
diary::commands::DeleteReviewCommand, diary::delete_review, test_helpers::TestContextBuilder, diary::commands::DeleteReviewCommand, diary::delete_review, diary::deps::DeleteReviewDeps,
}; };
fn make_movie() -> Movie { fn make_movie() -> Movie {
@@ -51,15 +51,15 @@ async fn test_delete_review_removes_it() {
reviews.save_review(&review).await.unwrap(); reviews.save_review(&review).await.unwrap();
diary.seed_history(movie.clone(), vec![]); diary.seed_history(movie.clone(), vec![]);
let ctx = TestContextBuilder::new() let deps = DeleteReviewDeps {
.with_movies(Arc::clone(&movies) as _) review: Arc::clone(&reviews) as _,
.with_reviews(Arc::clone(&reviews) as _) diary: diary.clone() as _,
.with_diary(Arc::clone(&diary) as _) movie: Arc::clone(&movies) as _,
.with_event_publisher(Arc::clone(&events) as _) event_publisher: Arc::clone(&events) as _,
.build(); };
delete_review::execute( delete_review::execute(
&ctx, &deps,
DeleteReviewCommand { DeleteReviewCommand {
review_id: review.id().value(), review_id: review.id().value(),
requesting_user_id: user_id.value(), requesting_user_id: user_id.value(),
@@ -78,6 +78,9 @@ async fn test_delete_review_removes_it() {
#[tokio::test] #[tokio::test]
async fn test_delete_review_wrong_user_is_unauthorized() { async fn test_delete_review_wrong_user_is_unauthorized() {
let reviews = InMemoryReviewRepository::new(); let reviews = InMemoryReviewRepository::new();
let diary = FakeDiaryRepository::new();
let movies = InMemoryMovieRepository::new();
let events = NoopEventPublisher::new();
let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4()); let movie_id = MovieId::from_uuid(uuid::Uuid::new_v4());
let owner_id = UserId::from_uuid(uuid::Uuid::new_v4()); let owner_id = UserId::from_uuid(uuid::Uuid::new_v4());
@@ -86,12 +89,15 @@ async fn test_delete_review_wrong_user_is_unauthorized() {
reviews.save_review(&review).await.unwrap(); reviews.save_review(&review).await.unwrap();
let ctx = TestContextBuilder::new() let deps = DeleteReviewDeps {
.with_reviews(Arc::clone(&reviews) as _) review: Arc::clone(&reviews) as _,
.build(); diary: diary as _,
movie: movies as _,
event_publisher: Arc::clone(&events) as _,
};
let result = delete_review::execute( let result = delete_review::execute(
&ctx, &deps,
DeleteReviewCommand { DeleteReviewCommand {
review_id: review.id().value(), review_id: review.id().value(),
requesting_user_id: other_id, requesting_user_id: other_id,

View File

@@ -2,18 +2,27 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use domain::errors::DomainError; use domain::errors::DomainError;
use domain::testing::{FakeDiaryRepository, NoopSocialQueryPort};
use crate::{ use crate::{
diary::get_activity_feed, diary::queries::GetActivityFeedQuery, config::AppConfig, diary::deps::GetActivityFeedDeps, diary::get_activity_feed,
test_helpers::TestContextBuilder, diary::queries::GetActivityFeedQuery, test_helpers::TestContextBuilder,
}; };
fn default_deps() -> GetActivityFeedDeps {
GetActivityFeedDeps {
diary: FakeDiaryRepository::new() as _,
social_query: Arc::new(NoopSocialQueryPort),
config: TestContextBuilder::new().config,
}
}
#[tokio::test] #[tokio::test]
async fn returns_empty_feed() { async fn returns_empty_feed() {
let ctx = TestContextBuilder::new().build(); let deps = default_deps();
let result = get_activity_feed::execute( let result = get_activity_feed::execute(
&ctx, &deps,
GetActivityFeedQuery { GetActivityFeedQuery {
limit: 10, limit: 10,
offset: 0, offset: 0,
@@ -32,12 +41,12 @@ async fn returns_empty_feed() {
#[tokio::test] #[tokio::test]
async fn returns_feed_with_following_filter() { async fn returns_feed_with_following_filter() {
let ctx = TestContextBuilder::new().build(); let deps = default_deps();
let viewer = uuid::Uuid::new_v4(); let viewer = uuid::Uuid::new_v4();
let result = get_activity_feed::execute( let result = get_activity_feed::execute(
&ctx, &deps,
GetActivityFeedQuery { GetActivityFeedQuery {
limit: 10, limit: 10,
offset: 0, offset: 0,
@@ -93,12 +102,24 @@ async fn following_filter_parses_local_and_remote_urls() {
let social = Arc::new(FakeSocialWithFollowing(following_urls)); let social = Arc::new(FakeSocialWithFollowing(following_urls));
let ctx = TestContextBuilder::new() let deps = GetActivityFeedDeps {
.with_social_query(social as _) diary: FakeDiaryRepository::new() as _,
.build(); social_query: social as _,
config: AppConfig {
allow_registration: true,
base_url: "http://localhost:3000".into(),
rate_limit: 20,
refresh_ttl_seconds: 2_592_000,
wrapup: crate::config::WrapUpConfig {
font_path: None,
logo_path: None,
bg_dir: None,
},
},
};
let result = get_activity_feed::execute( let result = get_activity_feed::execute(
&ctx, &deps,
GetActivityFeedQuery { GetActivityFeedQuery {
limit: 10, limit: 10,
offset: 0, offset: 0,
@@ -118,10 +139,10 @@ async fn following_filter_parses_local_and_remote_urls() {
#[tokio::test] #[tokio::test]
async fn following_filter_without_viewer_returns_none() { async fn following_filter_without_viewer_returns_none() {
let ctx = TestContextBuilder::new().build(); let deps = default_deps();
let result = get_activity_feed::execute( let result = get_activity_feed::execute(
&ctx, &deps,
GetActivityFeedQuery { GetActivityFeedQuery {
limit: 10, limit: 10,
offset: 0, offset: 0,

View File

@@ -1,11 +1,14 @@
use crate::{diary::get_diary, diary::queries::GetDiaryQuery, test_helpers::TestContextBuilder}; use domain::testing::FakeDiaryRepository;
use std::sync::Arc;
use crate::{diary::get_diary, diary::queries::GetDiaryQuery};
#[tokio::test] #[tokio::test]
async fn returns_empty_page() { async fn returns_empty_page() {
let ctx = TestContextBuilder::new().build(); let diary = FakeDiaryRepository::new() as Arc<dyn domain::ports::DiaryRepository>;
let result = get_diary::execute( let result = get_diary::execute(
&ctx, &diary,
GetDiaryQuery { GetDiaryQuery {
limit: None, limit: None,
offset: None, offset: None,

View File

@@ -5,21 +5,25 @@ use uuid::Uuid;
use domain::{ use domain::{
models::Movie, models::Movie,
ports::MovieRepository, ports::MovieRepository,
testing::InMemoryMovieRepository, testing::{FakeDiaryRepository, InMemoryMovieProfileRepository, InMemoryMovieRepository},
value_objects::{MovieTitle, ReleaseYear}, value_objects::{MovieTitle, ReleaseYear},
}; };
use crate::{ use crate::{
diary::get_movie_social_page, diary::queries::GetMovieSocialPageQuery, diary::deps::GetMovieSocialPageDeps, diary::get_movie_social_page,
test_helpers::TestContextBuilder, diary::queries::GetMovieSocialPageQuery,
}; };
#[tokio::test] #[tokio::test]
async fn fails_when_movie_not_found() { async fn fails_when_movie_not_found() {
let ctx = TestContextBuilder::new().build(); let deps = GetMovieSocialPageDeps {
movie: InMemoryMovieRepository::new(),
diary: FakeDiaryRepository::new() as _,
movie_profile: InMemoryMovieProfileRepository::new(),
};
let result = get_movie_social_page::execute( let result = get_movie_social_page::execute(
&ctx, &deps,
GetMovieSocialPageQuery { GetMovieSocialPageQuery {
movie_id: Uuid::new_v4(), movie_id: Uuid::new_v4(),
limit: 10, limit: 10,
@@ -45,12 +49,14 @@ async fn returns_movie_social_page() {
let movie_uuid = movie.id().value(); let movie_uuid = movie.id().value();
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
let ctx = TestContextBuilder::new() let deps = GetMovieSocialPageDeps {
.with_movies(Arc::clone(&movies) as _) movie: Arc::clone(&movies) as _,
.build(); diary: FakeDiaryRepository::new() as _,
movie_profile: InMemoryMovieProfileRepository::new(),
};
let result = get_movie_social_page::execute( let result = get_movie_social_page::execute(
&ctx, &deps,
GetMovieSocialPageQuery { GetMovieSocialPageQuery {
movie_id: movie_uuid, movie_id: movie_uuid,
limit: 10, limit: 10,

View File

@@ -1,13 +1,13 @@
use std::sync::Arc;
use domain::{ use domain::{
models::Movie, models::Movie,
ports::DiaryRepository,
services::review_history::Trend, services::review_history::Trend,
value_objects::{MovieTitle, ReleaseYear}, value_objects::{MovieTitle, ReleaseYear},
}; };
use crate::{ use crate::{diary::get_review_history, diary::queries::GetReviewHistoryQuery};
diary::get_review_history, diary::queries::GetReviewHistoryQuery,
test_helpers::TestContextBuilder,
};
#[tokio::test] #[tokio::test]
async fn returns_empty_history() { async fn returns_empty_history() {
@@ -22,10 +22,9 @@ async fn returns_empty_history() {
let diary = domain::testing::FakeDiaryRepository::new(); let diary = domain::testing::FakeDiaryRepository::new();
diary.seed_history(movie, vec![]); diary.seed_history(movie, vec![]);
let diary: Arc<dyn DiaryRepository> = diary;
let ctx = TestContextBuilder::new().with_diary(diary as _).build(); let (history, trend) = get_review_history::execute(&diary, GetReviewHistoryQuery { movie_id })
let (history, trend) = get_review_history::execute(&ctx, GetReviewHistoryQuery { movie_id })
.await .await
.unwrap(); .unwrap();

View File

@@ -17,24 +17,18 @@ use crate::{
test_helpers::TestContextBuilder, test_helpers::TestContextBuilder,
}; };
fn build_ctx_with_real_logger( fn build_logger(
movies: &Arc<InMemoryMovieRepository>, movies: &Arc<InMemoryMovieRepository>,
reviews: &Arc<InMemoryReviewRepository>, reviews: &Arc<InMemoryReviewRepository>,
events: &Arc<NoopEventPublisher>, events: &Arc<NoopEventPublisher>,
) -> crate::context::AppContext { ) -> Arc<dyn crate::ports::ReviewLogger> {
let logger = Arc::new(DefaultReviewLogger::new( Arc::new(DefaultReviewLogger::new(
Arc::clone(movies) as _, Arc::clone(movies) as _,
Arc::clone(reviews) as _, Arc::clone(reviews) as _,
crate::test_helpers::TestContextBuilder::new().watchlist_repo, TestContextBuilder::new().watchlist_repo,
Arc::new(domain::testing::FakeMetadataClient) as _, Arc::new(domain::testing::FakeMetadataClient) as _,
Arc::clone(events) as _, Arc::clone(events) as _,
)); ))
TestContextBuilder::new()
.with_movies(Arc::clone(movies) as _)
.with_reviews(Arc::clone(reviews) as _)
.with_event_publisher(Arc::clone(events) as _)
.with_review_logger(logger)
.build()
} }
fn movie_input_manual(title: &str, year: u16) -> MovieInput { fn movie_input_manual(title: &str, year: u16) -> MovieInput {
@@ -62,7 +56,7 @@ async fn test_log_review_creates_movie_and_review() {
let movies = InMemoryMovieRepository::new(); let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new(); let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events); let logger = build_logger(&movies, &reviews, &events);
let user_id = uuid::Uuid::new_v4(); let user_id = uuid::Uuid::new_v4();
let cmd = LogReviewCommand { let cmd = LogReviewCommand {
@@ -73,7 +67,7 @@ async fn test_log_review_creates_movie_and_review() {
watched_at: Utc::now().naive_utc(), watched_at: Utc::now().naive_utc(),
}; };
log_review::execute(&ctx, cmd).await.unwrap(); log_review::execute(&logger, cmd).await.unwrap();
assert_eq!(reviews.count(), 1, "review should be saved"); assert_eq!(reviews.count(), 1, "review should be saved");
assert!(!events.published().is_empty(), "events should be published"); assert!(!events.published().is_empty(), "events should be published");
@@ -95,7 +89,7 @@ async fn test_log_review_reuses_existing_movie() {
movies.upsert_movie(&existing_movie).await.unwrap(); movies.upsert_movie(&existing_movie).await.unwrap();
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events); let logger = build_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand { let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(), user_id: uuid::Uuid::new_v4(),
@@ -105,7 +99,7 @@ async fn test_log_review_reuses_existing_movie() {
watched_at: Utc::now().naive_utc(), watched_at: Utc::now().naive_utc(),
}; };
log_review::execute(&ctx, cmd).await.unwrap(); log_review::execute(&logger, cmd).await.unwrap();
assert_eq!(movies.count(), 1, "no duplicate movie"); assert_eq!(movies.count(), 1, "no duplicate movie");
assert_eq!(reviews.count(), 1); assert_eq!(reviews.count(), 1);
@@ -116,7 +110,8 @@ async fn test_log_review_with_invalid_rating_fails() {
let movies = InMemoryMovieRepository::new(); let movies = InMemoryMovieRepository::new();
let reviews = InMemoryReviewRepository::new(); let reviews = InMemoryReviewRepository::new();
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = build_ctx_with_real_logger(&movies, &reviews, &events); let logger = build_logger(&movies, &reviews, &events);
let cmd = LogReviewCommand { let cmd = LogReviewCommand {
user_id: uuid::Uuid::new_v4(), user_id: uuid::Uuid::new_v4(),
input: movie_input_manual("Some Film", 2000), input: movie_input_manual("Some Film", 2000),
@@ -124,6 +119,6 @@ async fn test_log_review_with_invalid_rating_fails() {
comment: None, comment: None,
watched_at: Utc::now().naive_utc(), watched_at: Utc::now().naive_utc(),
}; };
let result = log_review::execute(&ctx, cmd).await; let result = log_review::execute(&logger, cmd).await;
assert!(result.is_err(), "rating > 5 should fail"); assert!(result.is_err(), "rating > 5 should fail");
} }

View File

@@ -1,48 +1,42 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
models::{Goal, GoalType, GoalWithProgress}, models::{Goal, GoalType, GoalWithProgress},
ports::{EventPublisher, GoalRepository},
value_objects::UserId, value_objects::UserId,
}; };
use super::commands::CreateGoalCommand; use super::commands::CreateGoalCommand;
use crate::context::AppContext;
pub async fn execute( pub async fn execute(
ctx: &AppContext, goal: Arc<dyn GoalRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: CreateGoalCommand, cmd: CreateGoalCommand,
) -> Result<GoalWithProgress, DomainError> { ) -> Result<GoalWithProgress, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let existing = ctx let existing = goal.find_by_user_and_year(&user_id, cmd.year).await?;
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year)
.await?;
if existing.is_some() { if existing.is_some() {
return Err(DomainError::ValidationError( return Err(DomainError::ValidationError(
"Goal already exists for this year".into(), "Goal already exists for this year".into(),
)); ));
} }
let goal = Goal::new( let g = Goal::new(
user_id.clone(), user_id.clone(),
cmd.year, cmd.year,
cmd.target_count, cmd.target_count,
GoalType::Movies, GoalType::Movies,
)?; )?;
ctx.repos.goal.save(&goal).await?; goal.save(&g).await?;
let current_count = ctx let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
.repos
.goal
.count_reviews_in_year(&user_id, cmd.year)
.await?;
ctx.services event_publisher
.event_publisher
.publish(&DomainEvent::GoalCreated { .publish(&DomainEvent::GoalCreated {
goal_id: goal.id().clone(), goal_id: g.id().clone(),
user_id, user_id,
year: cmd.year, year: cmd.year,
target_count: cmd.target_count, target_count: cmd.target_count,
@@ -50,7 +44,7 @@ pub async fn execute(
.await?; .await?;
Ok(GoalWithProgress { Ok(GoalWithProgress {
goal, goal: g,
current_count, current_count,
}) })
} }

View File

@@ -1,24 +1,31 @@
use domain::{errors::DomainError, events::DomainEvent, value_objects::UserId}; use std::sync::Arc;
use domain::{
errors::DomainError,
events::DomainEvent,
ports::{EventPublisher, GoalRepository},
value_objects::UserId,
};
use super::commands::DeleteGoalCommand; use super::commands::DeleteGoalCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteGoalCommand) -> Result<(), DomainError> { pub async fn execute(
goal: Arc<dyn GoalRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: DeleteGoalCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let goal = ctx let g = goal
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year) .find_by_user_and_year(&user_id, cmd.year)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
ctx.repos.goal.delete(goal.id(), &user_id).await?; goal.delete(g.id(), &user_id).await?;
ctx.services event_publisher
.event_publisher
.publish(&DomainEvent::GoalDeleted { .publish(&DomainEvent::GoalDeleted {
goal_id: goal.id().clone(), goal_id: g.id().clone(),
user_id, user_id,
year: cmd.year, year: cmd.year,
}) })

View File

@@ -1,30 +1,25 @@
use domain::{errors::DomainError, models::GoalWithProgress, value_objects::UserId}; use std::sync::Arc;
use domain::{
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
};
use super::queries::GetGoalQuery; use super::queries::GetGoalQuery;
use crate::context::AppContext;
pub async fn execute( pub async fn execute(
ctx: &AppContext, goal: Arc<dyn GoalRepository>,
query: GetGoalQuery, query: GetGoalQuery,
) -> Result<Option<GoalWithProgress>, DomainError> { ) -> Result<Option<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let goal = ctx let found = goal.find_by_user_and_year(&user_id, query.year).await?;
.repos
.goal
.find_by_user_and_year(&user_id, query.year)
.await?;
let Some(goal) = goal else { return Ok(None) }; let Some(g) = found else { return Ok(None) };
let current_count = ctx let current_count = goal.count_reviews_in_year(&user_id, query.year).await?;
.repos
.goal
.count_reviews_in_year(&user_id, query.year)
.await?;
Ok(Some(GoalWithProgress { Ok(Some(GoalWithProgress {
goal, goal: g,
current_count, current_count,
})) }))
} }

View File

@@ -1,24 +1,23 @@
use domain::{errors::DomainError, models::GoalWithProgress, value_objects::UserId}; use std::sync::Arc;
use domain::{
errors::DomainError, models::GoalWithProgress, ports::GoalRepository, value_objects::UserId,
};
use super::queries::ListGoalsQuery; use super::queries::ListGoalsQuery;
use crate::context::AppContext;
pub async fn execute( pub async fn execute(
ctx: &AppContext, goal: Arc<dyn GoalRepository>,
query: ListGoalsQuery, query: ListGoalsQuery,
) -> Result<Vec<GoalWithProgress>, DomainError> { ) -> Result<Vec<GoalWithProgress>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
let goals = ctx.repos.goal.list_for_user(&user_id).await?; let goals = goal.list_for_user(&user_id).await?;
let mut result = Vec::with_capacity(goals.len()); let mut result = Vec::with_capacity(goals.len());
for goal in goals { for g in goals {
let current_count = ctx let current_count = goal.count_reviews_in_year(&user_id, g.year()).await?;
.repos
.goal
.count_reviews_in_year(&user_id, goal.year())
.await?;
result.push(GoalWithProgress { result.push(GoalWithProgress {
goal, goal: g,
current_count, current_count,
}); });
} }

View File

@@ -10,15 +10,11 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn creates_goal_and_returns_progress() { async fn creates_goal_and_returns_progress() {
let goals = InMemoryGoalRepository::new(); let goals = InMemoryGoalRepository::new();
goals.set_review_count(Uuid::nil(), 2025, 5);
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_goal(Arc::clone(&goals) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
let result = create::execute( let result = create::execute(
&ctx, Arc::clone(&goals) as _,
Arc::clone(&events) as _,
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -30,19 +26,40 @@ async fn creates_goal_and_returns_progress() {
assert_eq!(result.goal.year(), 2025); assert_eq!(result.goal.year(), 2025);
assert_eq!(result.goal.target_count(), 50); assert_eq!(result.goal.target_count(), 50);
assert_eq!(result.current_count, 0);
assert_eq!(goals.count(), 1);
}
#[tokio::test]
async fn creates_goal_with_review_count() {
let goals = InMemoryGoalRepository::new();
goals.set_review_count(Uuid::nil(), 2025, 5);
let events = NoopEventPublisher::new();
let result = create::execute(
Arc::clone(&goals) as _,
Arc::clone(&events) as _,
CreateGoalCommand {
user_id: Uuid::nil(),
year: 2025,
target_count: 50,
},
)
.await
.unwrap();
assert_eq!(result.current_count, 5); assert_eq!(result.current_count, 5);
assert_eq!(goals.count(), 1); assert_eq!(goals.count(), 1);
} }
#[tokio::test] #[tokio::test]
async fn emits_goal_created_event() { async fn emits_goal_created_event() {
let b = TestContextBuilder::new();
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_event_publisher(Arc::clone(&events) as _)
.build();
create::execute( create::execute(
&ctx, b.goal_repo.clone(),
Arc::clone(&events) as _,
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -62,17 +79,20 @@ async fn emits_goal_created_event() {
#[tokio::test] #[tokio::test]
async fn rejects_duplicate_year() { async fn rejects_duplicate_year() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let cmd = CreateGoalCommand { let cmd = CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
target_count: 10, target_count: 10,
}; };
create::execute(&ctx, cmd).await.unwrap(); create::execute(b.goal_repo.clone(), b.event_publisher.clone(), cmd)
.await
.unwrap();
let result = create::execute( let result = create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -86,9 +106,10 @@ async fn rejects_duplicate_year() {
#[tokio::test] #[tokio::test]
async fn rejects_year_before_2020() { async fn rejects_year_before_2020() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = create::execute( let result = create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2019, year: 2019,
@@ -102,9 +123,10 @@ async fn rejects_year_before_2020() {
#[tokio::test] #[tokio::test]
async fn rejects_zero_target() { async fn rejects_zero_target() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = create::execute( let result = create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,

View File

@@ -13,13 +13,10 @@ use crate::test_helpers::TestContextBuilder;
async fn deletes_existing_goal() { async fn deletes_existing_goal() {
let goals = InMemoryGoalRepository::new(); let goals = InMemoryGoalRepository::new();
let events = NoopEventPublisher::new(); let events = NoopEventPublisher::new();
let ctx = TestContextBuilder::new()
.with_goal(Arc::clone(&goals) as _)
.with_event_publisher(Arc::clone(&events) as _)
.build();
create::execute( create::execute(
&ctx, Arc::clone(&goals) as _,
Arc::clone(&events) as _,
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -31,7 +28,8 @@ async fn deletes_existing_goal() {
assert_eq!(goals.count(), 1); assert_eq!(goals.count(), 1);
delete::execute( delete::execute(
&ctx, Arc::clone(&goals) as _,
Arc::clone(&events) as _,
DeleteGoalCommand { DeleteGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -45,9 +43,10 @@ async fn deletes_existing_goal() {
#[tokio::test] #[tokio::test]
async fn fails_when_not_found() { async fn fails_when_not_found() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = delete::execute( let result = delete::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
DeleteGoalCommand { DeleteGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,

View File

@@ -5,9 +5,10 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn returns_goal_when_exists() { async fn returns_goal_when_exists() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
create::execute( create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -18,7 +19,7 @@ async fn returns_goal_when_exists() {
.unwrap(); .unwrap();
let result = get::execute( let result = get::execute(
&ctx, b.goal_repo.clone(),
GetGoalQuery { GetGoalQuery {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -33,9 +34,9 @@ async fn returns_goal_when_exists() {
#[tokio::test] #[tokio::test]
async fn returns_none_when_missing() { async fn returns_none_when_missing() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = get::execute( let result = get::execute(
&ctx, b.goal_repo.clone(),
GetGoalQuery { GetGoalQuery {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,

View File

@@ -5,9 +5,9 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn returns_empty_when_no_goals() { async fn returns_empty_when_no_goals() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = list::execute( let result = list::execute(
&ctx, b.goal_repo.clone(),
ListGoalsQuery { ListGoalsQuery {
user_id: Uuid::nil(), user_id: Uuid::nil(),
}, },
@@ -20,10 +20,11 @@ async fn returns_empty_when_no_goals() {
#[tokio::test] #[tokio::test]
async fn returns_all_goals_for_user() { async fn returns_all_goals_for_user() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
for year in [2023, 2024, 2025] { for year in [2023, 2024, 2025] {
create::execute( create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year, year,
@@ -35,7 +36,7 @@ async fn returns_all_goals_for_user() {
} }
let result = list::execute( let result = list::execute(
&ctx, b.goal_repo.clone(),
ListGoalsQuery { ListGoalsQuery {
user_id: Uuid::nil(), user_id: Uuid::nil(),
}, },

View File

@@ -8,9 +8,10 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn updates_target_count() { async fn updates_target_count() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
create::execute( create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -21,7 +22,8 @@ async fn updates_target_count() {
.unwrap(); .unwrap();
let result = update::execute( let result = update::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand { UpdateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -36,9 +38,10 @@ async fn updates_target_count() {
#[tokio::test] #[tokio::test]
async fn fails_when_goal_not_found() { async fn fails_when_goal_not_found() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
let result = update::execute( let result = update::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand { UpdateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -52,9 +55,10 @@ async fn fails_when_goal_not_found() {
#[tokio::test] #[tokio::test]
async fn rejects_zero_target() { async fn rejects_zero_target() {
let ctx = TestContextBuilder::new().build(); let b = TestContextBuilder::new();
create::execute( create::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
CreateGoalCommand { CreateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,
@@ -65,7 +69,8 @@ async fn rejects_zero_target() {
.unwrap(); .unwrap();
let result = update::execute( let result = update::execute(
&ctx, b.goal_repo.clone(),
b.event_publisher.clone(),
UpdateGoalCommand { UpdateGoalCommand {
user_id: Uuid::nil(), user_id: Uuid::nil(),
year: 2025, year: 2025,

View File

@@ -1,36 +1,35 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, events::DomainEvent, models::GoalWithProgress, value_objects::UserId, errors::DomainError,
events::DomainEvent,
models::GoalWithProgress,
ports::{EventPublisher, GoalRepository},
value_objects::UserId,
}; };
use super::commands::UpdateGoalCommand; use super::commands::UpdateGoalCommand;
use crate::context::AppContext;
pub async fn execute( pub async fn execute(
ctx: &AppContext, goal: Arc<dyn GoalRepository>,
event_publisher: Arc<dyn EventPublisher>,
cmd: UpdateGoalCommand, cmd: UpdateGoalCommand,
) -> Result<GoalWithProgress, DomainError> { ) -> Result<GoalWithProgress, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let mut goal = ctx let mut g = goal
.repos
.goal
.find_by_user_and_year(&user_id, cmd.year) .find_by_user_and_year(&user_id, cmd.year)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?; .ok_or_else(|| DomainError::NotFound(format!("Goal for year {}", cmd.year)))?;
goal.update_target(cmd.target_count)?; g.update_target(cmd.target_count)?;
ctx.repos.goal.update(&goal).await?; goal.update(&g).await?;
let current_count = ctx let current_count = goal.count_reviews_in_year(&user_id, cmd.year).await?;
.repos
.goal
.count_reviews_in_year(&user_id, cmd.year)
.await?;
ctx.services event_publisher
.event_publisher
.publish(&DomainEvent::GoalUpdated { .publish(&DomainEvent::GoalUpdated {
goal_id: goal.id().clone(), goal_id: g.id().clone(),
user_id, user_id,
year: cmd.year, year: cmd.year,
target_count: cmd.target_count, target_count: cmd.target_count,
@@ -38,7 +37,7 @@ pub async fn execute(
.await?; .await?;
Ok(GoalWithProgress { Ok(GoalWithProgress {
goal, goal: g,
current_count, current_count,
}) })
} }

View File

@@ -1,21 +1,24 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{AnnotatedRow, import::RowResult}, models::{AnnotatedRow, import::RowResult},
ports::{DocumentParser, ImportSessionRepository, MovieRepository},
value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId}, value_objects::{ExternalMetadataId, ImportSessionId, MovieTitle, ReleaseYear, UserId},
}; };
use crate::{context::AppContext, import::commands::ApplyImportMappingCommand}; use crate::import::commands::ApplyImportMappingCommand;
pub async fn execute( pub async fn execute(
ctx: &AppContext, import_session: Arc<dyn ImportSessionRepository>,
document_parser: Arc<dyn DocumentParser>,
movie: Arc<dyn MovieRepository>,
cmd: ApplyImportMappingCommand, cmd: ApplyImportMappingCommand,
) -> Result<Vec<AnnotatedRow>, DomainError> { ) -> Result<Vec<AnnotatedRow>, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let mappings = cmd.mappings; let mappings = cmd.mappings;
let mut session = ctx let mut session = import_session
.repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -25,22 +28,22 @@ pub async fn execute(
.clone() .clone()
.ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?; .ok_or_else(|| DomainError::ValidationError("session has no parsed file".into()))?;
let mut annotated = ctx let mut annotated = document_parser.apply_mapping(&parsed, &mappings);
.services
.document_parser
.apply_mapping(&parsed, &mappings);
mark_duplicates(ctx, &mut annotated).await?; mark_duplicates(movie, &mut annotated).await?;
session.field_mappings = Some(mappings); session.field_mappings = Some(mappings);
session.row_results = Some(annotated.clone()); session.row_results = Some(annotated.clone());
ctx.repos.import_session.update(&session).await?; import_session.update(&session).await?;
Ok(annotated) Ok(annotated)
} }
async fn mark_duplicates(ctx: &AppContext, rows: &mut [AnnotatedRow]) -> Result<(), DomainError> { async fn mark_duplicates(
movie: Arc<dyn MovieRepository>,
rows: &mut [AnnotatedRow],
) -> Result<(), DomainError> {
let mut ext_ids = Vec::new(); let mut ext_ids = Vec::new();
let mut title_year_pairs = Vec::new(); let mut title_year_pairs = Vec::new();
@@ -63,12 +66,8 @@ async fn mark_duplicates(ctx: &AppContext, rows: &mut [AnnotatedRow]) -> Result<
} }
} }
let known_ext = ctx.repos.movie.existing_external_ids(&ext_ids).await?; let known_ext = movie.existing_external_ids(&ext_ids).await?;
let known_ty = ctx let known_ty = movie.existing_title_year_pairs(&title_year_pairs).await?;
.repos
.movie
.existing_title_year_pairs(&title_year_pairs)
.await?;
for row in rows.iter_mut() { for row in rows.iter_mut() {
if let RowResult::Valid(ref r) = row.result { if let RowResult::Valid(ref r) = row.result {

View File

@@ -1,31 +1,34 @@
use crate::{context::AppContext, import::commands::ApplyImportProfileCommand}; use std::sync::Arc;
use crate::import::commands::ApplyImportProfileCommand;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::{ImportProfileRepository, ImportSessionRepository},
value_objects::{ImportProfileId, ImportSessionId, UserId}, value_objects::{ImportProfileId, ImportSessionId, UserId},
}; };
/// Copies the profile's field_mappings onto the session. Caller must then invoke /// Copies the profile's field_mappings onto the session. Caller must then invoke
/// apply_import_mapping to regenerate row_results with the new mappings. /// apply_import_mapping to regenerate row_results with the new mappings.
pub async fn execute(ctx: &AppContext, cmd: ApplyImportProfileCommand) -> Result<(), DomainError> { pub async fn execute(
import_profile: Arc<dyn ImportProfileRepository>,
import_session: Arc<dyn ImportSessionRepository>,
cmd: ApplyImportProfileCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id); let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
let profile = ctx let profile = import_profile
.repos
.import_profile
.get(&profile_id, &user_id) .get(&profile_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?; .ok_or_else(|| DomainError::NotFound("import profile".into()))?;
let mut session = ctx let mut session = import_session
.repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
session.field_mappings = Some(profile.field_mappings); session.field_mappings = Some(profile.field_mappings);
session.row_results = None; session.row_results = None;
ctx.repos.import_session.update(&session).await import_session.update(&session).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,8 +1,9 @@
use crate::context::AppContext; use std::sync::Arc;
use domain::errors::DomainError;
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> { use domain::{errors::DomainError, ports::ImportSessionRepository};
ctx.repos.import_session.delete_expired().await
pub async fn execute(import_session: Arc<dyn ImportSessionRepository>) -> Result<u64, DomainError> {
import_session.delete_expired().await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,11 +1,13 @@
use chrono::Utc; use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportSession, models::ImportSession,
ports::{DocumentParser, ImportSessionRepository},
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
}; };
use crate::{context::AppContext, import::commands::CreateImportSessionCommand}; use crate::import::commands::CreateImportSessionCommand;
pub struct CreateSessionResult { pub struct CreateSessionResult {
pub session_id: ImportSessionId, pub session_id: ImportSessionId,
@@ -14,30 +16,25 @@ pub struct CreateSessionResult {
} }
pub async fn execute( pub async fn execute(
ctx: &AppContext, import_session: Arc<dyn ImportSessionRepository>,
document_parser: Arc<dyn DocumentParser>,
cmd: CreateImportSessionCommand, cmd: CreateImportSessionCommand,
) -> Result<CreateSessionResult, DomainError> { ) -> Result<CreateSessionResult, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
ctx.repos import_session.delete_expired_for_user(&user_id).await?;
.import_session
.delete_expired_for_user(&user_id)
.await?;
let parsed = ctx let parsed = document_parser
.services
.document_parser
.parse(&cmd.bytes, cmd.format) .parse(&cmd.bytes, cmd.format)
.map_err(|e| DomainError::ValidationError(e.to_string()))?; .map_err(|e| DomainError::ValidationError(e.to_string()))?;
let sample_rows = parsed.rows.iter().take(5).cloned().collect(); let sample_rows = parsed.rows.iter().take(5).cloned().collect();
let columns = parsed.columns.clone(); let columns = parsed.columns.clone();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(user_id);
let mut session = ImportSession::new(ImportSessionId::generate(), user_id, now);
let session_id = session.id.clone(); let session_id = session.id.clone();
session.parsed_file = Some(parsed); session.parsed_file = Some(parsed);
ctx.repos.import_session.create(&session).await?; import_session.create(&session).await?;
Ok(CreateSessionResult { Ok(CreateSessionResult {
session_id, session_id,

View File

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

View File

@@ -1,17 +1,22 @@
use std::sync::Arc;
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{ImportRow, import::RowResult}, models::{ImportRow, import::RowResult},
ports::ImportSessionRepository,
value_objects::{ImportSessionId, UserId}, value_objects::{ImportSessionId, UserId},
}; };
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
context::AppContext,
diary::commands::{LogReviewCommand, MovieInput}, diary::commands::{LogReviewCommand, MovieInput},
import::commands::ExecuteImportCommand, import::commands::ExecuteImportCommand,
ports::ReviewLogger,
}; };
const CONCURRENCY_LIMIT: usize = 10;
pub struct ImportSummary { pub struct ImportSummary {
pub imported: usize, pub imported: usize,
pub skipped_duplicates: usize, pub skipped_duplicates: usize,
@@ -19,15 +24,14 @@ pub struct ImportSummary {
} }
pub async fn execute( pub async fn execute(
ctx: &AppContext, import_session: Arc<dyn ImportSessionRepository>,
review_logger: Arc<dyn ReviewLogger>,
cmd: ExecuteImportCommand, cmd: ExecuteImportCommand,
) -> Result<ImportSummary, DomainError> { ) -> Result<ImportSummary, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let confirmed_indices = cmd.confirmed_indices; let confirmed_indices = cmd.confirmed_indices;
let session = ctx let session = import_session
.repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -39,26 +43,42 @@ pub async fn execute(
let mut skipped_duplicates = 0; let mut skipped_duplicates = 0;
let mut failed = Vec::new(); let mut failed = Vec::new();
let semaphore = Arc::new(tokio::sync::Semaphore::new(CONCURRENCY_LIMIT));
let mut tasks: tokio::task::JoinSet<(usize, Result<(), String>)> = tokio::task::JoinSet::new();
for (idx, annotated) in row_results.into_iter().enumerate() { for (idx, annotated) in row_results.into_iter().enumerate() {
if !confirmed_set.contains(&idx) { if !confirmed_set.contains(&idx) {
skipped_duplicates += 1; skipped_duplicates += 1;
continue; continue;
} }
match annotated.result { match annotated.result {
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
Ok(cmd) => match ctx.services.review_logger.log_review(cmd).await {
Ok(_) => imported += 1,
Err(e) => failed.push((idx, e.to_string())),
},
Err(e) => failed.push((idx, e)),
},
RowResult::Invalid { errors, .. } => { RowResult::Invalid { errors, .. } => {
failed.push((idx, errors.join("; "))); failed.push((idx, errors.join("; ")));
} }
RowResult::Valid(row) => match row_to_command(&row, user_id.value()) {
Err(e) => failed.push((idx, e)),
Ok(log_cmd) => {
let permit = Arc::clone(&semaphore).acquire_owned().await.unwrap();
let logger = Arc::clone(&review_logger);
tasks.spawn(async move {
let result = logger.log_review(log_cmd).await.map_err(|e| e.to_string());
drop(permit);
(idx, result)
});
}
},
} }
} }
ctx.repos.import_session.delete(&session_id).await?; while let Some(res) = tasks.join_next().await {
let (idx, outcome) = res.expect("import task panicked");
match outcome {
Ok(()) => imported += 1,
Err(e) => failed.push((idx, e)),
}
}
import_session.delete(&session_id).await?;
Ok(ImportSummary { Ok(ImportSummary {
imported, imported,

View File

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

View File

@@ -1,21 +1,23 @@
use crate::{context::AppContext, import::commands::SaveImportProfileCommand}; use std::sync::Arc;
use crate::import::commands::SaveImportProfileCommand;
use chrono::Utc; use chrono::Utc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportProfile, models::ImportProfile,
ports::{ImportProfileRepository, ImportSessionRepository},
value_objects::{ImportProfileId, ImportSessionId, UserId}, value_objects::{ImportProfileId, ImportSessionId, UserId},
}; };
pub async fn execute( pub async fn execute(
ctx: &AppContext, import_session: Arc<dyn ImportSessionRepository>,
import_profile: Arc<dyn ImportProfileRepository>,
cmd: SaveImportProfileCommand, cmd: SaveImportProfileCommand,
) -> Result<ImportProfileId, DomainError> { ) -> Result<ImportProfileId, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let session_id = ImportSessionId::from_uuid(cmd.session_id); let session_id = ImportSessionId::from_uuid(cmd.session_id);
let session = ctx let session = import_session
.repos
.import_session
.get(&session_id, &user_id) .get(&session_id, &user_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?; .ok_or_else(|| DomainError::NotFound("import session".into()))?;
@@ -30,7 +32,7 @@ pub async fn execute(
Utc::now().naive_utc(), Utc::now().naive_utc(),
); );
let id = profile.id.clone(); let id = profile.id.clone();
ctx.repos.import_profile.save(&profile).await?; import_profile.save(&profile).await?;
Ok(id) Ok(id)
} }

View File

@@ -8,7 +8,7 @@ use domain::{
import::{ImportRow, ParsedFile, RowResult}, import::{ImportRow, ParsedFile, RowResult},
}, },
ports::{DocumentParser, MovieRepository}, ports::{DocumentParser, MovieRepository},
testing::InMemoryMovieRepository, testing::{InMemoryImportSessionRepository, InMemoryMovieRepository},
value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear}, value_objects::{ExternalMetadataId, MovieTitle, ReleaseYear},
}; };
@@ -21,11 +21,13 @@ use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn applies_mapping_to_session() { async fn applies_mapping_to_session() {
let ctx = TestContextBuilder::new().build(); let sessions = InMemoryImportSessionRepository::new();
let b = TestContextBuilder::new();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let session = create_session::execute( let session = create_session::execute(
&ctx, Arc::clone(&sessions) as _,
b.document_parser.clone(),
CreateImportSessionCommand { CreateImportSessionCommand {
user_id, user_id,
bytes: b"title\nTest".to_vec(), bytes: b"title\nTest".to_vec(),
@@ -36,7 +38,9 @@ async fn applies_mapping_to_session() {
.unwrap(); .unwrap();
let rows = apply_mapping::execute( let rows = apply_mapping::execute(
&ctx, Arc::clone(&sessions) as _,
b.document_parser.clone(),
b.movie_repo.clone(),
ApplyImportMappingCommand { ApplyImportMappingCommand {
user_id, user_id,
session_id: session.session_id.value(), session_id: session.session_id.value(),
@@ -51,10 +55,13 @@ async fn applies_mapping_to_session() {
#[tokio::test] #[tokio::test]
async fn fails_when_session_not_found() { async fn fails_when_session_not_found() {
let ctx = TestContextBuilder::new().build(); let sessions = InMemoryImportSessionRepository::new();
let b = TestContextBuilder::new();
let result = apply_mapping::execute( let result = apply_mapping::execute(
&ctx, Arc::clone(&sessions) as _,
b.document_parser.clone(),
b.movie_repo.clone(),
ApplyImportMappingCommand { ApplyImportMappingCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
session_id: Uuid::new_v4(), session_id: Uuid::new_v4(),
@@ -102,6 +109,7 @@ impl DocumentParser for DuplicateTestParser {
#[tokio::test] #[tokio::test]
async fn marks_duplicate_by_external_id() { async fn marks_duplicate_by_external_id() {
let movies = InMemoryMovieRepository::new(); let movies = InMemoryMovieRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap(); let ext_id = ExternalMetadataId::new("tt1234567".into()).unwrap();
let movie = Movie::new( let movie = Movie::new(
@@ -113,23 +121,20 @@ async fn marks_duplicate_by_external_id() {
); );
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
let parser = DuplicateTestParser { let parser = Arc::new(DuplicateTestParser {
rows: vec![ImportRow { rows: vec![ImportRow {
title: Some("Known Movie".into()), title: Some("Known Movie".into()),
release_year: Some("2020".into()), release_year: Some("2020".into()),
external_metadata_id: Some("tt1234567".into()), external_metadata_id: Some("tt1234567".into()),
..ImportRow::default() ..ImportRow::default()
}], }],
}; });
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_document_parser(Arc::new(parser) as _)
.build();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let session = create_session::execute( let session = create_session::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&parser) as _,
CreateImportSessionCommand { CreateImportSessionCommand {
user_id, user_id,
bytes: b"title\nKnown Movie".to_vec(), bytes: b"title\nKnown Movie".to_vec(),
@@ -140,7 +145,9 @@ async fn marks_duplicate_by_external_id() {
.unwrap(); .unwrap();
let rows = apply_mapping::execute( let rows = apply_mapping::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&parser) as _,
Arc::clone(&movies) as _,
ApplyImportMappingCommand { ApplyImportMappingCommand {
user_id, user_id,
session_id: session.session_id.value(), session_id: session.session_id.value(),
@@ -157,6 +164,7 @@ async fn marks_duplicate_by_external_id() {
#[tokio::test] #[tokio::test]
async fn marks_duplicate_by_title_and_year() { async fn marks_duplicate_by_title_and_year() {
let movies = InMemoryMovieRepository::new(); let movies = InMemoryMovieRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let movie = Movie::new( let movie = Movie::new(
None, None,
@@ -167,22 +175,19 @@ async fn marks_duplicate_by_title_and_year() {
); );
movies.upsert_movie(&movie).await.unwrap(); movies.upsert_movie(&movie).await.unwrap();
let parser = DuplicateTestParser { let parser = Arc::new(DuplicateTestParser {
rows: vec![ImportRow { rows: vec![ImportRow {
title: Some("Duplicate Film".into()), title: Some("Duplicate Film".into()),
release_year: Some("2022".into()), release_year: Some("2022".into()),
..ImportRow::default() ..ImportRow::default()
}], }],
}; });
let ctx = TestContextBuilder::new()
.with_movies(Arc::clone(&movies) as _)
.with_document_parser(Arc::new(parser) as _)
.build();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let session = create_session::execute( let session = create_session::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&parser) as _,
CreateImportSessionCommand { CreateImportSessionCommand {
user_id, user_id,
bytes: b"title\nDuplicate Film".to_vec(), bytes: b"title\nDuplicate Film".to_vec(),
@@ -193,7 +198,9 @@ async fn marks_duplicate_by_title_and_year() {
.unwrap(); .unwrap();
let rows = apply_mapping::execute( let rows = apply_mapping::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&parser) as _,
Arc::clone(&movies) as _,
ApplyImportMappingCommand { ApplyImportMappingCommand {
user_id, user_id,
session_id: session.session_id.value(), session_id: session.session_id.value(),

View File

@@ -3,19 +3,20 @@ use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use domain::models::ImportProfile; use domain::models::ImportProfile;
use domain::ports::{ImportProfileRepository, ImportSessionRepository}; use domain::ports::{ImportProfileRepository, ImportSessionRepository};
use domain::testing::InMemoryImportProfileRepository; use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
use domain::value_objects::{ImportProfileId, UserId}; use domain::value_objects::{ImportProfileId, UserId};
use uuid::Uuid; use uuid::Uuid;
use crate::import::{apply_profile, commands::ApplyImportProfileCommand}; use crate::import::{apply_profile, commands::ApplyImportProfileCommand};
use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn fails_when_profile_not_found() { async fn fails_when_profile_not_found() {
let ctx = TestContextBuilder::new().build(); let profiles = InMemoryImportProfileRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let result = apply_profile::execute( let result = apply_profile::execute(
&ctx, Arc::clone(&profiles) as _,
Arc::clone(&sessions) as _,
ApplyImportProfileCommand { ApplyImportProfileCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
session_id: Uuid::new_v4(), session_id: Uuid::new_v4(),
@@ -30,6 +31,7 @@ async fn fails_when_profile_not_found() {
#[tokio::test] #[tokio::test]
async fn fails_when_session_not_found() { async fn fails_when_session_not_found() {
let profiles = InMemoryImportProfileRepository::new(); let profiles = InMemoryImportProfileRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let profile = ImportProfile::new( let profile = ImportProfile::new(
@@ -42,12 +44,9 @@ async fn fails_when_session_not_found() {
let profile_id = profile.id.clone(); let profile_id = profile.id.clone();
profiles.save(&profile).await.unwrap(); profiles.save(&profile).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_profiles(Arc::clone(&profiles) as _)
.build();
let result = apply_profile::execute( let result = apply_profile::execute(
&ctx, Arc::clone(&profiles) as _,
Arc::clone(&sessions) as _,
ApplyImportProfileCommand { ApplyImportProfileCommand {
user_id, user_id,
session_id: Uuid::new_v4(), session_id: Uuid::new_v4(),
@@ -79,21 +78,13 @@ async fn applies_profile_mappings_to_session() {
let profile_id = profile.id.clone(); let profile_id = profile.id.clone();
profiles.save(&profile).await.unwrap(); profiles.save(&profile).await.unwrap();
let session = domain::models::ImportSession::new( let session = domain::models::ImportSession::new(UserId::from_uuid(user_id));
domain::value_objects::ImportSessionId::generate(),
UserId::from_uuid(user_id),
Utc::now().naive_utc(),
);
let session_id = session.id.clone(); let session_id = session.id.clone();
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_profiles(Arc::clone(&profiles) as _)
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
apply_profile::execute( apply_profile::execute(
&ctx, Arc::clone(&profiles) as _,
Arc::clone(&sessions) as _,
ApplyImportProfileCommand { ApplyImportProfileCommand {
user_id, user_id,
session_id: session_id.value(), session_id: session_id.value(),

View File

@@ -3,16 +3,12 @@ use std::sync::Arc;
use domain::testing::InMemoryImportSessionRepository; use domain::testing::InMemoryImportSessionRepository;
use crate::import::cleanup; use crate::import::cleanup;
use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn returns_zero_when_nothing_expired() { async fn returns_zero_when_nothing_expired() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = cleanup::execute(&ctx).await.unwrap(); let result = cleanup::execute(Arc::clone(&sessions) as _).await.unwrap();
assert_eq!(result, 0); assert_eq!(result, 0);
} }

View File

@@ -1,14 +1,20 @@
use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
use domain::testing::InMemoryImportSessionRepository;
use crate::import::{commands::CreateImportSessionCommand, create_session}; use crate::import::{commands::CreateImportSessionCommand, create_session};
use crate::test_helpers::TestContextBuilder; use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn creates_session_with_parsed_file() { async fn creates_session_with_parsed_file() {
let ctx = TestContextBuilder::new().build(); let sessions = InMemoryImportSessionRepository::new();
let b = TestContextBuilder::new();
let result = create_session::execute( let result = create_session::execute(
&ctx, Arc::clone(&sessions) as _,
b.document_parser.clone(),
CreateImportSessionCommand { CreateImportSessionCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
bytes: b"col1\nval1".to_vec(), bytes: b"col1\nval1".to_vec(),

View File

@@ -4,17 +4,13 @@ use domain::testing::InMemoryImportProfileRepository;
use uuid::Uuid; use uuid::Uuid;
use crate::import::{commands::DeleteImportProfileCommand, delete_profile}; use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn fails_when_profile_not_found() { async fn fails_when_profile_not_found() {
let profiles = InMemoryImportProfileRepository::new(); let profiles = InMemoryImportProfileRepository::new();
let ctx = TestContextBuilder::new()
.with_import_profiles(Arc::clone(&profiles) as _)
.build();
let result = delete_profile::execute( let result = delete_profile::execute(
&ctx, Arc::clone(&profiles) as _,
DeleteImportProfileCommand { DeleteImportProfileCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
profile_id: Uuid::new_v4(), profile_id: Uuid::new_v4(),

View File

@@ -1,19 +1,17 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc;
use domain::models::{AnnotatedRow, ImportSession, import::RowResult}; use domain::models::{AnnotatedRow, ImportSession, import::RowResult};
use domain::ports::ImportSessionRepository; use domain::ports::ImportSessionRepository;
use domain::testing::InMemoryImportSessionRepository; use domain::testing::InMemoryImportSessionRepository;
use domain::value_objects::{ImportSessionId, UserId}; use domain::value_objects::UserId;
use uuid::Uuid; use uuid::Uuid;
use crate::import::commands::ExecuteImportCommand; use crate::import::commands::ExecuteImportCommand;
use crate::import::execute; use crate::import::execute;
use crate::test_helpers::TestContextBuilder; use crate::test_helpers::NoopReviewLogger;
fn make_session_with_rows(user_id: UserId, session_id: ImportSessionId) -> ImportSession { fn make_session_with_rows(user_id: UserId) -> ImportSession {
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(user_id);
let mut session = ImportSession::new(session_id, user_id, now);
session.row_results = Some(vec![ session.row_results = Some(vec![
AnnotatedRow { AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
@@ -47,17 +45,14 @@ fn make_session_with_rows(user_id: UserId, session_id: ImportSessionId) -> Impor
async fn imports_confirmed_rows() { async fn imports_confirmed_rows() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); let session = make_session_with_rows(UserId::from_uuid(uid));
let sid = session.id.clone();
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -76,17 +71,14 @@ async fn imports_confirmed_rows() {
async fn skips_unconfirmed_rows() { async fn skips_unconfirmed_rows() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); let session = make_session_with_rows(UserId::from_uuid(uid));
let sid = session.id.clone();
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -102,9 +94,11 @@ async fn skips_unconfirmed_rows() {
#[tokio::test] #[tokio::test]
async fn fails_when_session_not_found() { async fn fails_when_session_not_found() {
let ctx = TestContextBuilder::new().build(); let sessions = InMemoryImportSessionRepository::new();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
session_id: Uuid::new_v4(), session_id: Uuid::new_v4(),
@@ -120,10 +114,9 @@ async fn fails_when_session_not_found() {
async fn handles_datetime_format() { async fn handles_datetime_format() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("DateTime Movie".into()), title: Some("DateTime Movie".into()),
@@ -138,12 +131,9 @@ async fn handles_datetime_format() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -161,10 +151,9 @@ async fn handles_datetime_format() {
async fn fails_on_invalid_rating() { async fn fails_on_invalid_rating() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("Bad Rating Movie".into()), title: Some("Bad Rating Movie".into()),
@@ -179,12 +168,9 @@ async fn fails_on_invalid_rating() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -202,10 +188,9 @@ async fn fails_on_invalid_rating() {
async fn fails_on_missing_watched_at() { async fn fails_on_missing_watched_at() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("No Date Movie".into()), title: Some("No Date Movie".into()),
@@ -220,12 +205,9 @@ async fn fails_on_missing_watched_at() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -243,10 +225,9 @@ async fn fails_on_missing_watched_at() {
async fn imports_row_with_external_metadata_id() { async fn imports_row_with_external_metadata_id() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("TMDB Movie".into()), title: Some("TMDB Movie".into()),
@@ -261,12 +242,9 @@ async fn imports_row_with_external_metadata_id() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -284,10 +262,9 @@ async fn imports_row_with_external_metadata_id() {
async fn imports_row_with_director_and_comment() { async fn imports_row_with_director_and_comment() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("Directed Movie".into()), title: Some("Directed Movie".into()),
@@ -302,12 +279,9 @@ async fn imports_row_with_director_and_comment() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -325,10 +299,9 @@ async fn imports_row_with_director_and_comment() {
async fn handles_space_separated_datetime_format() { async fn handles_space_separated_datetime_format() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("Space DateTime".into()), title: Some("Space DateTime".into()),
@@ -343,12 +316,9 @@ async fn handles_space_separated_datetime_format() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -366,10 +336,9 @@ async fn handles_space_separated_datetime_format() {
async fn reports_invalid_row_result_errors() { async fn reports_invalid_row_result_errors() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Invalid { result: RowResult::Invalid {
errors: vec!["missing title".into(), "bad year".into()], errors: vec!["missing title".into(), "bad year".into()],
@@ -379,12 +348,9 @@ async fn reports_invalid_row_result_errors() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -404,10 +370,9 @@ async fn reports_invalid_row_result_errors() {
async fn fails_on_missing_rating() { async fn fails_on_missing_rating() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("No Rating Movie".into()), title: Some("No Rating Movie".into()),
@@ -422,12 +387,9 @@ async fn fails_on_missing_rating() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -446,10 +408,9 @@ async fn fails_on_missing_rating() {
async fn fails_on_unparseable_date() { async fn fails_on_unparseable_date() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("Bad Date Movie".into()), title: Some("Bad Date Movie".into()),
@@ -464,12 +425,9 @@ async fn fails_on_unparseable_date() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -488,10 +446,9 @@ async fn fails_on_unparseable_date() {
async fn imports_row_without_release_year() { async fn imports_row_without_release_year() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let now = Utc::now().naive_utc(); let mut session = ImportSession::new(UserId::from_uuid(uid));
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let sid = session.id.clone();
session.row_results = Some(vec![AnnotatedRow { session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
title: Some("No Year Movie".into()), title: Some("No Year Movie".into()),
@@ -506,12 +463,9 @@ async fn imports_row_without_release_year() {
}]); }]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = execute::execute( let result = execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -529,18 +483,15 @@ async fn imports_row_without_release_year() {
async fn deletes_session_after_import() { async fn deletes_session_after_import() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4(); let uid = Uuid::new_v4();
let sid = ImportSessionId::generate();
let session = make_session_with_rows(UserId::from_uuid(uid), sid.clone()); let session = make_session_with_rows(UserId::from_uuid(uid));
let sid = session.id.clone();
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
assert_eq!(sessions.count(), 1); assert_eq!(sessions.count(), 1);
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
execute::execute( execute::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand { ExecuteImportCommand {
user_id: uid, user_id: uid,
session_id: sid.value(), session_id: sid.value(),
@@ -556,3 +507,46 @@ async fn deletes_session_after_import() {
"session should be deleted after import" "session should be deleted after import"
); );
} }
#[tokio::test]
async fn imports_more_rows_than_concurrency_limit() {
let sessions = InMemoryImportSessionRepository::new();
let uid = Uuid::new_v4();
let rows: Vec<_> = (0..15)
.map(|i| AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow {
title: Some(format!("Movie {i}")),
release_year: Some("2024".into()),
rating: Some("4".into()),
watched_at: Some("2024-06-01".into()),
external_metadata_id: None,
director: None,
comment: None,
}),
is_duplicate: false,
})
.collect();
let mut session = ImportSession::new(UserId::from_uuid(uid));
let sid = session.id.clone();
session.row_results = Some(rows);
sessions.create(&session).await.unwrap();
let confirmed_indices: Vec<usize> = (0..15).collect();
let result = execute::execute(
Arc::clone(&sessions) as _,
Arc::new(NoopReviewLogger),
ExecuteImportCommand {
user_id: uid,
session_id: sid.value(),
confirmed_indices,
},
)
.await
.unwrap();
assert_eq!(result.imported, 15);
assert_eq!(result.skipped_duplicates, 0);
assert!(result.failed.is_empty());
}

View File

@@ -5,17 +5,15 @@ use domain::value_objects::UserId;
use uuid::Uuid; use uuid::Uuid;
use crate::import::list_profiles; use crate::import::list_profiles;
use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn returns_empty_when_no_profiles() { async fn returns_empty_when_no_profiles() {
let profiles = InMemoryImportProfileRepository::new(); let profiles = InMemoryImportProfileRepository::new();
let ctx = TestContextBuilder::new()
.with_import_profiles(Arc::clone(&profiles) as _)
.build();
let user_id = UserId::from_uuid(Uuid::new_v4()); let user_id = UserId::from_uuid(Uuid::new_v4());
let result = list_profiles::execute(&ctx, &user_id).await.unwrap(); let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id)
.await
.unwrap();
assert!(result.is_empty()); assert!(result.is_empty());
} }

View File

@@ -1,24 +1,21 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc;
use domain::models::ImportSession; use domain::models::ImportSession;
use domain::ports::ImportSessionRepository; use domain::ports::ImportSessionRepository;
use domain::testing::InMemoryImportSessionRepository; use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
use domain::value_objects::{ImportSessionId, UserId}; use domain::value_objects::UserId;
use uuid::Uuid; use uuid::Uuid;
use crate::import::{commands::SaveImportProfileCommand, save_profile}; use crate::import::{commands::SaveImportProfileCommand, save_profile};
use crate::test_helpers::TestContextBuilder;
#[tokio::test] #[tokio::test]
async fn fails_when_session_not_found() { async fn fails_when_session_not_found() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let ctx = TestContextBuilder::new() let profiles = InMemoryImportProfileRepository::new();
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = save_profile::execute( let result = save_profile::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&profiles) as _,
SaveImportProfileCommand { SaveImportProfileCommand {
user_id: Uuid::new_v4(), user_id: Uuid::new_v4(),
session_id: Uuid::new_v4(), session_id: Uuid::new_v4(),
@@ -33,23 +30,17 @@ async fn fails_when_session_not_found() {
#[tokio::test] #[tokio::test]
async fn saves_profile_from_session() { async fn saves_profile_from_session() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let profiles = InMemoryImportProfileRepository::new();
let user_id = Uuid::new_v4(); let user_id = Uuid::new_v4();
let sid = ImportSessionId::generate();
let mut session = ImportSession::new( let mut session = ImportSession::new(UserId::from_uuid(user_id));
sid.clone(), let sid = session.id.clone();
UserId::from_uuid(user_id),
Utc::now().naive_utc(),
);
session.field_mappings = Some(vec![]); session.field_mappings = Some(vec![]);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();
let ctx = TestContextBuilder::new()
.with_import_sessions(Arc::clone(&sessions) as _)
.build();
let result = save_profile::execute( let result = save_profile::execute(
&ctx, Arc::clone(&sessions) as _,
Arc::clone(&profiles) as _,
SaveImportProfileCommand { SaveImportProfileCommand {
user_id, user_id,
session_id: sid.value(), session_id: sid.value(),

View File

@@ -1,14 +1,11 @@
use std::sync::Arc;
use chrono::Duration; use chrono::Duration;
use domain::errors::DomainError; use domain::{errors::DomainError, ports::WatchEventRepository};
use crate::context::AppContext; pub async fn execute(watch_event: Arc<dyn WatchEventRepository>) -> Result<u64, DomainError> {
pub async fn execute(ctx: &AppContext) -> Result<u64, DomainError> {
let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30); let cutoff = chrono::Utc::now().naive_utc() - Duration::days(30);
ctx.repos watch_event.delete_non_pending_older_than(cutoff).await
.watch_event
.delete_non_pending_older_than(cutoff)
.await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,24 +1,29 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::WatchEventStatus, models::WatchEventStatus,
ports::WatchEventRepository,
value_objects::{UserId, WatchEventId}, value_objects::{UserId, WatchEventId},
}; };
use crate::{ use crate::{
context::AppContext,
diary::commands::{LogReviewCommand, MovieInput}, diary::commands::{LogReviewCommand, MovieInput},
integrations::commands::ConfirmWatchEventsCommand, integrations::commands::ConfirmWatchEventsCommand,
ports::ReviewLogger,
}; };
pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result<u32, DomainError> { pub async fn execute(
watch_event: Arc<dyn WatchEventRepository>,
review_logger: Arc<dyn ReviewLogger>,
cmd: ConfirmWatchEventsCommand,
) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let mut confirmed = 0u32; let mut confirmed = 0u32;
for c in cmd.confirmations { for c in cmd.confirmations {
let event_id = WatchEventId::from_uuid(c.watch_event_id); let event_id = WatchEventId::from_uuid(c.watch_event_id);
let event = ctx let event = watch_event
.repos
.watch_event
.get_by_id(&event_id) .get_by_id(&event_id)
.await? .await?
.ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?; .ok_or_else(|| DomainError::NotFound(format!("WatchEvent {}", c.watch_event_id)))?;
@@ -53,10 +58,9 @@ pub async fn execute(ctx: &AppContext, cmd: ConfirmWatchEventsCommand) -> Result
watched_at: *event.watched_at(), watched_at: *event.watched_at(),
}; };
ctx.services.review_logger.log_review(review_cmd).await?; review_logger.log_review(review_cmd).await?;
ctx.repos watch_event
.watch_event
.update_status(&event_id, WatchEventStatus::Confirmed) .update_status(&event_id, WatchEventStatus::Confirmed)
.await?; .await?;

View File

@@ -0,0 +1,9 @@
use std::sync::Arc;
use domain::ports::{EventPublisher, WatchEventRepository, WebhookTokenRepository};
pub struct IngestWatchEventDeps {
pub webhook_token: Arc<dyn WebhookTokenRepository>,
pub watch_event: Arc<dyn WatchEventRepository>,
pub event_publisher: Arc<dyn EventPublisher>,
}

View File

@@ -1,12 +1,18 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::WatchEventStatus, models::WatchEventStatus,
ports::WatchEventRepository,
value_objects::{UserId, WatchEventId}, value_objects::{UserId, WatchEventId},
}; };
use crate::{context::AppContext, integrations::commands::DismissWatchEventsCommand}; use crate::integrations::commands::DismissWatchEventsCommand;
pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result<u32, DomainError> { pub async fn execute(
watch_event: Arc<dyn WatchEventRepository>,
cmd: DismissWatchEventsCommand,
) -> Result<u32, DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
if cmd.event_ids.is_empty() { if cmd.event_ids.is_empty() {
return Ok(0); return Ok(0);
@@ -18,7 +24,7 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
.map(|id| WatchEventId::from_uuid(*id)) .map(|id| WatchEventId::from_uuid(*id))
.collect(); .collect();
let events = ctx.repos.watch_event.get_by_ids(&ids).await?; let events = watch_event.get_by_ids(&ids).await?;
if events.len() != ids.len() { if events.len() != ids.len() {
return Err(DomainError::NotFound( return Err(DomainError::NotFound(
@@ -31,9 +37,7 @@ pub async fn execute(ctx: &AppContext, cmd: DismissWatchEventsCommand) -> Result
} }
} }
let count = ctx let count = watch_event
.repos
.watch_event
.update_status_batch(&ids, WatchEventStatus::Dismissed) .update_status_batch(&ids, WatchEventStatus::Dismissed)
.await?; .await?;

View File

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

View File

@@ -1,13 +1,17 @@
use domain::{errors::DomainError, models::WatchEvent, value_objects::UserId}; use std::sync::Arc;
use crate::{context::AppContext, integrations::queries::GetWatchQueueQuery}; use domain::{
errors::DomainError, models::WatchEvent, ports::WatchEventRepository, value_objects::UserId,
};
use crate::integrations::queries::GetWatchQueueQuery;
pub async fn execute( pub async fn execute(
ctx: &AppContext, watch_event: Arc<dyn WatchEventRepository>,
query: GetWatchQueueQuery, query: GetWatchQueueQuery,
) -> Result<Vec<WatchEvent>, DomainError> { ) -> Result<Vec<WatchEvent>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
ctx.repos.watch_event.list_pending(&user_id).await watch_event.list_pending(&user_id).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -1,13 +1,17 @@
use domain::{errors::DomainError, models::WebhookToken, value_objects::UserId}; use std::sync::Arc;
use crate::{context::AppContext, integrations::queries::GetWebhookTokensQuery}; use domain::{
errors::DomainError, models::WebhookToken, ports::WebhookTokenRepository, value_objects::UserId,
};
use crate::integrations::queries::GetWebhookTokensQuery;
pub async fn execute( pub async fn execute(
ctx: &AppContext, webhook_token: Arc<dyn WebhookTokenRepository>,
query: GetWebhookTokensQuery, query: GetWebhookTokensQuery,
) -> Result<Vec<WebhookToken>, DomainError> { ) -> Result<Vec<WebhookToken>, DomainError> {
let user_id = UserId::from_uuid(query.user_id); let user_id = UserId::from_uuid(query.user_id);
ctx.repos.webhook_token.list_by_user(&user_id).await webhook_token.list_by_user(&user_id).await
} }
#[cfg(test)] #[cfg(test)]

View File

@@ -3,26 +3,21 @@ use domain::{
errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser, errors::DomainError, events::DomainEvent, models::WatchEvent, ports::MediaServerParser,
}; };
use crate::{context::AppContext, integrations::commands::IngestWatchEventCommand}; use crate::integrations::{commands::IngestWatchEventCommand, deps::IngestWatchEventDeps};
pub async fn execute( pub async fn execute(
ctx: &AppContext, deps: &IngestWatchEventDeps,
cmd: IngestWatchEventCommand, cmd: IngestWatchEventCommand,
parser: &dyn MediaServerParser, parser: &dyn MediaServerParser,
) -> Result<(), DomainError> { ) -> Result<(), DomainError> {
let token_hash = super::generate_token::hash_token(&cmd.token); let token_hash = super::generate_token::hash_token(&cmd.token);
let webhook_token = ctx let webhook_token = deps
.repos
.webhook_token .webhook_token
.find_by_token_hash(&token_hash) .find_by_token_hash(&token_hash)
.await? .await?
.ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?; .ok_or_else(|| DomainError::Unauthorized("invalid webhook token".into()))?;
let _ = ctx let _ = deps.webhook_token.touch_last_used(webhook_token.id()).await;
.repos
.webhook_token
.touch_last_used(webhook_token.id())
.await;
let parsed = match parser.parse_playback_event(&cmd.raw_payload)? { let parsed = match parser.parse_playback_event(&cmd.raw_payload)? {
Some(event) => event, Some(event) => event,
@@ -34,8 +29,7 @@ pub async fn execute(
if let Some(ref ext_id) = external_metadata_id { if let Some(ref ext_id) = external_metadata_id {
let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1); let one_hour_ago = chrono::Utc::now().naive_utc() - Duration::hours(1);
if ctx if deps
.repos
.watch_event .watch_event
.find_duplicate(&user_id, ext_id, one_hour_ago) .find_duplicate(&user_id, ext_id, one_hour_ago)
.await? .await?
@@ -55,10 +49,9 @@ pub async fn execute(
None, None,
); );
ctx.repos.watch_event.save(&event).await?; deps.watch_event.save(&event).await?;
let _ = ctx let _ = deps
.services
.event_publisher .event_publisher
.publish(&DomainEvent::WatchEventIngested { .publish(&DomainEvent::WatchEventIngested {
user_id: event.user_id().clone(), user_id: event.user_id().clone(),

View File

@@ -1,6 +1,7 @@
pub mod cleanup; pub mod cleanup;
pub mod commands; pub mod commands;
pub mod confirm; pub mod confirm;
pub mod deps;
pub mod dismiss; pub mod dismiss;
pub mod generate_token; pub mod generate_token;
pub mod get_queue; pub mod get_queue;

View File

@@ -1,14 +1,20 @@
use std::sync::Arc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
ports::WebhookTokenRepository,
value_objects::{UserId, WebhookTokenId}, value_objects::{UserId, WebhookTokenId},
}; };
use crate::{context::AppContext, integrations::commands::RevokeWebhookTokenCommand}; use crate::integrations::commands::RevokeWebhookTokenCommand;
pub async fn execute(ctx: &AppContext, cmd: RevokeWebhookTokenCommand) -> Result<(), DomainError> { pub async fn execute(
webhook_token: Arc<dyn WebhookTokenRepository>,
cmd: RevokeWebhookTokenCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id); let user_id = UserId::from_uuid(cmd.user_id);
let token_id = WebhookTokenId::from_uuid(cmd.token_id); let token_id = WebhookTokenId::from_uuid(cmd.token_id);
ctx.repos.webhook_token.delete(&token_id, &user_id).await webhook_token.delete(&token_id, &user_id).await
} }
#[cfg(test)] #[cfg(test)]

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