Compare commits

...

17 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
65 changed files with 933 additions and 391 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"

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,51 +1,89 @@
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 title = csv_escape(e.movie().title().value()); let header = futures::stream::once(async {
let year = e.movie().release_year().value(); Ok(Bytes::from_static(
let director = e.movie().director().map(csv_escape).unwrap_or_default(); b"title,year,director,rating,comment,watched_at,external_metadata_id\n",
let rating = e.review().rating().value(); ))
let comment = e });
.review() let rows = entries.map(|r| r.map(|e| Bytes::from(csv_row(&e))));
.comment() Box::pin(header.chain(rows))
.map(|c| csv_escape(c.value())) }
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d"); fn stream_json(
let ext_id = e stream: BoxStream<'static, Result<DiaryEntry, DomainError>>,
.movie() ) -> BoxStream<'static, Result<Bytes, DomainError>> {
.external_metadata_id() Box::pin(async_stream::stream! {
.map(|id| id.value().to_string()) futures::pin_mut!(stream);
.unwrap_or_default(); let mut is_first = true;
out.push_str(&format!( while let Some(r) = futures::StreamExt::next(&mut stream).await {
"{},{},{},{},{},{},{}\n", match r {
title, year, director, rating, comment, watched_at, ext_id Err(e) => { yield Err(e); return; }
)); Ok(entry) => {
} let json = serde_json::to_string(&entry_to_json(&entry))
Ok(out.into_bytes()) .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 year = e.movie().release_year().value();
let director = e.movie().director().map(csv_escape).unwrap_or_default();
let rating = e.review().rating().value();
let comment = e
.review()
.comment()
.map(|c| csv_escape(c.value()))
.unwrap_or_default();
let watched_at = e.review().watched_at().format("%Y-%m-%d");
let ext_id = e
.movie()
.external_metadata_id()
.map(|id| id.value().to_string())
.unwrap_or_default();
format!(
"{},{},{},{},{},{},{}\n",
title, year, director, rating, comment, watched_at, ext_id
)
} }
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 serde_json::json!({
.iter() "title": e.movie().title().value(),
.map(|e| { "year": e.movie().release_year().value(),
serde_json::json!({ "director": e.movie().director(),
"title": e.movie().title().value(), "rating": e.review().rating().value(),
"year": e.movie().release_year().value(), "comment": e.review().comment().map(|c| c.value().to_string()),
"director": e.movie().director(), "watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"rating": e.review().rating().value(), "external_metadata_id": e.movie().external_metadata_id().map(|id| id.value().to_string()),
"comment": e.review().comment().map(|c| c.value()), })
"watched_at": e.review().watched_at().format("%Y-%m-%d").to_string(),
"external_metadata_id": e.movie().external_metadata_id().map(|id| id.value()),
})
})
.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

@@ -8,6 +8,7 @@ use domain::{
ports::DiaryRepository, ports::DiaryRepository,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use futures::stream::BoxStream;
use sqlx::PgPool; use sqlx::PgPool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
@@ -427,6 +428,35 @@ impl DiaryRepository for PostgresDiaryRepository {
rows.into_iter().map(DiaryRow::into_domain).collect() 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> { async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string(); let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>( sqlx::query_as::<_, MovieStatsRow>(

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

View File

@@ -93,6 +93,7 @@ pub struct PostgresWireOutput {
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>, pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>, pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>, pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
} }
@@ -108,6 +109,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
.map_err(|e| anyhow::anyhow!("{e}")) .map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?; .context("Database migration failed")?;
let user_settings_repo = std::sync::Arc::new(
user_settings::PostgresUserSettingsRepository::new(pool.clone()),
);
Ok(PostgresWireOutput { Ok(PostgresWireOutput {
pool: pool.clone(), pool: pool.clone(),
movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _, movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _,
@@ -125,9 +130,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _, wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::PostgresUserSettingsRepository::new( user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
pool.clone(), federation_settings: user_settings_repo as _,
)) as _,
remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool)) remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool))
as _, as _,
}) })

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,20 +26,25 @@ 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) )
.fetch_optional(&self.pool) .bind(&uid)
.await .fetch_optional(&self.pool)
.map_err(Self::map_err)?; .await
.map_err(Self::map_err)?;
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

@@ -8,6 +8,7 @@ use domain::{
ports::DiaryRepository, ports::DiaryRepository,
value_objects::{MovieId, UserId}, value_objects::{MovieId, UserId},
}; };
use futures::stream::BoxStream;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow};
@@ -389,6 +390,32 @@ impl DiaryRepository for SqliteDiaryRepository {
rows.into_iter().map(DiaryRow::into_domain).collect() 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> { async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError> {
let id_str = movie_id.value().to_string(); let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>( sqlx::query_as::<_, MovieStatsRow>(

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)?,
}) }))
} }
} }

View File

@@ -89,6 +89,7 @@ pub struct SqliteWireOutput {
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>, pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>, pub goal: std::sync::Arc<dyn domain::ports::GoalRepository>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>, pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
} }
@@ -113,6 +114,10 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
.map_err(|e| anyhow::anyhow!("{e}")) .map_err(|e| anyhow::anyhow!("{e}"))
.context("Database migration failed")?; .context("Database migration failed")?;
let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(
pool.clone(),
));
Ok(SqliteWireOutput { Ok(SqliteWireOutput {
pool: pool.clone(), pool: pool.clone(),
movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _, movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _,
@@ -128,9 +133,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result<SqliteWireOutput> {
wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _, wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _,
wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _,
goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _,
user_settings: std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new( user_settings: std::sync::Arc::clone(&user_settings_repo) as _,
pool.clone(), federation_settings: user_settings_repo as _,
)) as _,
remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _, remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _,
}) })
} }

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,20 +26,25 @@ 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) )
.fetch_optional(&self.pool) .bind(&uid)
.await .fetch_optional(&self.pool)
.map_err(Self::map_err)?; .await
.map_err(Self::map_err)?;
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) \
.bind(&uid) VALUES (?, ?, ?, ?)",
.bind(federate) )
.execute(&self.pool) .bind(&uid)
.await .bind(if settings.federate_goals() { 1i64 } else { 0 })
.map_err(Self::map_err)?; .bind(if settings.federate_reviews() { 1i64 } else { 0 })
.bind(if settings.federate_watchlist() {
1i64
} else {
0
})
.execute(&self.pool)
.await
.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

@@ -83,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 {

View File

@@ -40,6 +40,7 @@ impl EventHandler for PersonEnrichmentHandler {
_ => return Ok(()), _ => return Ok(()),
}; };
application::person::enrich::execute(&self.deps, 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

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

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

@@ -1,6 +1,5 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::ImportSession, models::ImportSession,
@@ -31,8 +30,7 @@ pub async fn execute(
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);

View File

@@ -78,11 +78,7 @@ 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();

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::NoopReviewLogger; 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,9 +45,9 @@ 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 result = execute::execute( let result = execute::execute(
@@ -73,9 +71,9 @@ 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 result = execute::execute( let result = execute::execute(
@@ -116,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()),
@@ -154,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()),
@@ -192,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()),
@@ -230,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()),
@@ -268,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()),
@@ -306,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()),
@@ -344,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,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()),
@@ -418,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()),
@@ -457,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()),
@@ -495,9 +483,9 @@ 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);
@@ -524,9 +512,7 @@ async fn deletes_session_after_import() {
async fn imports_more_rows_than_concurrency_limit() { async fn imports_more_rows_than_concurrency_limit() {
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 rows: Vec<_> = (0..15) let rows: Vec<_> = (0..15)
.map(|i| AnnotatedRow { .map(|i| AnnotatedRow {
result: RowResult::Valid(domain::models::ImportRow { result: RowResult::Valid(domain::models::ImportRow {
@@ -542,7 +528,8 @@ async fn imports_more_rows_than_concurrency_limit() {
}) })
.collect(); .collect();
let mut session = ImportSession::new(sid.clone(), UserId::from_uuid(uid), now); let mut session = ImportSession::new(UserId::from_uuid(uid));
let sid = session.id.clone();
session.row_results = Some(rows); session.row_results = Some(rows);
sessions.create(&session).await.unwrap(); sessions.create(&session).await.unwrap();

View File

@@ -1,10 +1,9 @@
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::{InMemoryImportProfileRepository, 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};
@@ -33,13 +32,9 @@ async fn saves_profile_from_session() {
let sessions = InMemoryImportSessionRepository::new(); let sessions = InMemoryImportSessionRepository::new();
let profiles = InMemoryImportProfileRepository::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();

View File

@@ -6,6 +6,7 @@ use domain::{
errors::DomainError, errors::DomainError,
events::DomainEvent, events::DomainEvent,
ports::{EventPublisher, MovieProfileRepository, PeriodicJob}, ports::{EventPublisher, MovieProfileRepository, PeriodicJob},
value_objects::ExternalMetadataId,
}; };
pub struct EnrichmentStalenessJob { pub struct EnrichmentStalenessJob {
@@ -38,9 +39,16 @@ impl PeriodicJob for EnrichmentStalenessJob {
} }
tracing::info!("enrichment scan: {} stale movies", stale.len()); tracing::info!("enrichment scan: {} stale movies", stale.len());
for (movie_id, external_metadata_id) in stale { for (movie_id, external_metadata_id) in stale {
let ext_id = match ExternalMetadataId::new(external_metadata_id) {
Ok(id) => id,
Err(e) => {
tracing::warn!("skipping stale movie with malformed external_metadata_id: {e}");
continue;
}
};
let event = DomainEvent::MovieEnrichmentRequested { let event = DomainEvent::MovieEnrichmentRequested {
movie_id, movie_id,
external_metadata_id, external_metadata_id: ext_id,
}; };
self.event_publisher.publish(&event).await?; self.event_publisher.publish(&event).await?;
} }

View File

@@ -18,7 +18,7 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<Option<Person
.event_publisher .event_publisher
.publish(&DomainEvent::PersonEnrichmentRequested { .publish(&DomainEvent::PersonEnrichmentRequested {
person_id: id, person_id: id,
external_person_id: p.external_id().value().to_string(), external_person_id: p.external_id().clone(),
}) })
.await; .await;
} }

View File

@@ -16,7 +16,7 @@ pub async fn execute(deps: &GetPersonDeps, id: PersonId) -> Result<PersonCredits
.event_publisher .event_publisher
.publish(&DomainEvent::PersonEnrichmentRequested { .publish(&DomainEvent::PersonEnrichmentRequested {
person_id: id, person_id: id,
external_person_id: credits.person.external_id().value().to_string(), external_person_id: credits.person.external_id().clone(),
}) })
.await; .await;
} }

View File

@@ -11,5 +11,7 @@ async fn returns_default_settings() {
.await .await
.unwrap(); .unwrap();
assert!(!settings.federate_goals()); assert!(settings.federate_goals());
assert!(settings.federate_reviews());
assert!(settings.federate_watchlist());
} }

View File

@@ -13,7 +13,31 @@ async fn updates_federate_goals() {
let settings_repo = InMemoryUserSettingsRepository::new(); let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone(); let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil();
crate::users::update_settings::execute(
user_settings.clone(),
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: false,
federate_reviews: true,
federate_watchlist: true,
},
)
.await
.unwrap();
let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(!settings.federate_goals());
assert!(settings.federate_reviews());
assert!(settings.federate_watchlist());
}
#[tokio::test]
async fn updates_federate_reviews() {
let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil(); let uid = Uuid::nil();
crate::users::update_settings::execute( crate::users::update_settings::execute(
@@ -21,6 +45,8 @@ async fn updates_federate_goals() {
UpdateUserSettingsCommand { UpdateUserSettingsCommand {
user_id: uid, user_id: uid,
federate_goals: true, federate_goals: true,
federate_reviews: false,
federate_watchlist: true,
}, },
) )
.await .await
@@ -28,4 +54,31 @@ async fn updates_federate_goals() {
let settings = get_settings::execute(user_settings, uid).await.unwrap(); let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(settings.federate_goals()); assert!(settings.federate_goals());
assert!(!settings.federate_reviews());
assert!(settings.federate_watchlist());
}
#[tokio::test]
async fn updates_federate_watchlist() {
let settings_repo = InMemoryUserSettingsRepository::new();
let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _);
let user_settings = b.user_settings_repo.clone();
let uid = Uuid::nil();
crate::users::update_settings::execute(
user_settings.clone(),
UpdateUserSettingsCommand {
user_id: uid,
federate_goals: true,
federate_reviews: true,
federate_watchlist: false,
},
)
.await
.unwrap();
let settings = get_settings::execute(user_settings, uid).await.unwrap();
assert!(settings.federate_goals());
assert!(settings.federate_reviews());
assert!(!settings.federate_watchlist());
} }

View File

@@ -5,6 +5,8 @@ use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects::
pub struct UpdateUserSettingsCommand { pub struct UpdateUserSettingsCommand {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub federate_goals: bool, pub federate_goals: bool,
pub federate_reviews: bool,
pub federate_watchlist: bool,
} }
pub async fn execute( pub async fn execute(
@@ -14,6 +16,8 @@ pub async fn execute(
let uid = UserId::from_uuid(cmd.user_id); let uid = UserId::from_uuid(cmd.user_id);
let mut settings = user_settings.get(&uid).await?; let mut settings = user_settings.get(&uid).await?;
settings.set_federate_goals(cmd.federate_goals); settings.set_federate_goals(cmd.federate_goals);
settings.set_federate_reviews(cmd.federate_reviews);
settings.set_federate_watchlist(cmd.federate_watchlist);
user_settings.save(&settings).await user_settings.save(&settings).await
} }

View File

@@ -3,7 +3,7 @@ use chrono::NaiveDateTime;
use crate::{ use crate::{
errors::DomainError, errors::DomainError,
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,
}, },
@@ -42,11 +42,11 @@ pub enum DomainEvent {
}, },
MovieEnrichmentRequested { MovieEnrichmentRequested {
movie_id: MovieId, movie_id: MovieId,
external_metadata_id: String, external_metadata_id: ExternalMetadataId,
}, },
PersonEnrichmentRequested { PersonEnrichmentRequested {
person_id: PersonId, person_id: PersonId,
external_person_id: String, external_person_id: ExternalPersonId,
}, },
ImageStored { ImageStored {
key: String, key: String,

View File

@@ -15,11 +15,22 @@ pub struct ImportSession {
pub expires_at: NaiveDateTime, pub expires_at: NaiveDateTime,
} }
pub struct PersistedImportSession {
pub id: ImportSessionId,
pub user_id: UserId,
pub parsed_file: Option<ParsedFile>,
pub field_mappings: Option<Vec<FieldMapping>>,
pub row_results: Option<Vec<AnnotatedRow>>,
pub created_at: NaiveDateTime,
pub expires_at: NaiveDateTime,
}
impl ImportSession { impl ImportSession {
pub fn new(id: ImportSessionId, user_id: UserId, created_at: NaiveDateTime) -> Self { pub fn new(user_id: UserId) -> Self {
let created_at = chrono::Utc::now().naive_utc();
let expires_at = created_at + chrono::Duration::hours(24); let expires_at = created_at + chrono::Duration::hours(24);
Self { Self {
id, id: ImportSessionId::generate(),
user_id, user_id,
parsed_file: None, parsed_file: None,
field_mappings: None, field_mappings: None,
@@ -28,4 +39,16 @@ impl ImportSession {
expires_at, expires_at,
} }
} }
pub fn from_persistence(p: PersistedImportSession) -> Self {
Self {
id: p.id,
user_id: p.user_id,
parsed_file: p.parsed_file,
field_mappings: p.field_mappings,
row_results: p.row_results,
created_at: p.created_at,
expires_at: p.expires_at,
}
}
} }

View File

@@ -4,27 +4,34 @@ use crate::value_objects::UserId;
pub struct UserSettings { pub struct UserSettings {
user_id: UserId, user_id: UserId,
federate_goals: bool, federate_goals: bool,
federate_reviews: bool,
federate_watchlist: bool,
} }
impl UserSettings { impl UserSettings {
pub fn new(user_id: UserId) -> Self { pub fn new(user_id: UserId) -> Self {
Self { Self {
user_id, user_id,
federate_goals: false, federate_goals: true,
federate_reviews: true,
federate_watchlist: true,
} }
} }
pub fn from_persistence(user_id: UserId, federate_goals: bool) -> Self { pub fn from_persistence(
user_id: UserId,
federate_goals: bool,
federate_reviews: bool,
federate_watchlist: bool,
) -> Self {
Self { Self {
user_id, user_id,
federate_goals, federate_goals,
federate_reviews,
federate_watchlist,
} }
} }
pub fn set_federate_goals(&mut self, value: bool) {
self.federate_goals = value;
}
pub fn user_id(&self) -> &UserId { pub fn user_id(&self) -> &UserId {
&self.user_id &self.user_id
} }
@@ -32,4 +39,24 @@ impl UserSettings {
pub fn federate_goals(&self) -> bool { pub fn federate_goals(&self) -> bool {
self.federate_goals self.federate_goals
} }
pub fn set_federate_goals(&mut self, value: bool) {
self.federate_goals = value;
}
pub fn federate_reviews(&self) -> bool {
self.federate_reviews
}
pub fn set_federate_reviews(&mut self, value: bool) {
self.federate_reviews = value;
}
pub fn federate_watchlist(&self) -> bool {
self.federate_watchlist
}
pub fn set_federate_watchlist(&mut self, value: bool) {
self.federate_watchlist = value;
}
} }

View File

@@ -144,6 +144,10 @@ pub trait DiaryRepository: Send + Sync {
) -> Result<Paginated<FeedEntry>, DomainError>; ) -> Result<Paginated<FeedEntry>, DomainError>;
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>; async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError>;
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>; async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError>;
fn stream_user_history(
&self,
user_id: UserId,
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>;
async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError>; async fn get_movie_stats(&self, movie_id: &MovieId) -> Result<MovieStats, DomainError>;
async fn get_movie_social_feed( async fn get_movie_social_feed(
&self, &self,
@@ -253,13 +257,12 @@ pub trait PasswordHasher: Send + Sync {
async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>; async fn verify(&self, plain_password: &str, hash: &PasswordHash) -> Result<bool, DomainError>;
} }
#[async_trait]
pub trait DiaryExporter: Send + Sync { pub trait DiaryExporter: Send + Sync {
async fn serialize_entries( fn stream_entries(
&self, &self,
entries: &[DiaryEntry], stream: futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>,
format: ExportFormat, format: ExportFormat,
) -> Result<Vec<u8>, DomainError>; ) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>>;
} }
#[async_trait] #[async_trait]
@@ -453,6 +456,17 @@ pub trait UserSettingsRepository: Send + Sync {
async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>; async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>;
} }
pub struct FederationFlags {
pub goals: bool,
pub reviews: bool,
pub watchlist: bool,
}
#[async_trait]
pub trait UserFederationSettingsQuery: Send + Sync {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError>;
}
#[async_trait] #[async_trait]
pub trait RemoteGoalRepository: Send + Sync { pub trait RemoteGoalRepository: Send + Sync {
async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>; async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>;
@@ -499,8 +513,6 @@ pub trait LocalApContentQuery: Send + Sync {
limit: usize, limit: usize,
) -> Result<Vec<DiaryEntry>, DomainError>; ) -> Result<Vec<DiaryEntry>, DomainError>;
async fn get_user_federate_goals(&self, user_id: &UserId) -> Result<bool, DomainError>;
async fn get_goal_with_progress( async fn get_goal_with_progress(
&self, &self,
user_id: &UserId, user_id: &UserId,

View File

@@ -154,6 +154,13 @@ impl DiaryRepository for FakeDiaryRepository {
Ok(vec![]) Ok(vec![])
} }
fn stream_user_history(
&self,
_user_id: UserId,
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>> {
Box::pin(futures::stream::empty())
}
async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> { async fn get_movie_stats(&self, _movie_id: &MovieId) -> Result<MovieStats, DomainError> {
Ok(MovieStats { Ok(MovieStats {
total_count: 0, total_count: 0,

View File

@@ -18,10 +18,10 @@ use crate::{
collections::{PageParams, Paginated}, collections::{PageParams, Paginated},
}, },
ports::{ ports::{
GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, FederationFlags, GoalRepository, ImportProfileRepository, ImportSessionRepository,
MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository, MovieProfileRepository, MovieRepository, RefreshSessionRepository, ReviewRepository,
UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository,
WebhookTokenRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository,
}, },
value_objects::{ value_objects::{
Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle, Email, ExternalMetadataId, GoalId, ImportProfileId, ImportSessionId, MovieId, MovieTitle,
@@ -441,6 +441,22 @@ impl UserSettingsRepository for InMemoryUserSettingsRepository {
} }
} }
#[async_trait]
impl UserFederationSettingsQuery for InMemoryUserSettingsRepository {
async fn get_federation_flags(&self, user_id: &UserId) -> Result<FederationFlags, DomainError> {
let store = self.store.lock().unwrap();
let settings = store
.get(&user_id.value())
.cloned()
.unwrap_or_else(|| UserSettings::new(user_id.clone()));
Ok(FederationFlags {
goals: settings.federate_goals(),
reviews: settings.federate_reviews(),
watchlist: settings.federate_watchlist(),
})
}
}
// ── InMemoryWebhookTokenRepository ────────────────────────────────────────── // ── InMemoryWebhookTokenRepository ──────────────────────────────────────────
pub struct InMemoryWebhookTokenRepository { pub struct InMemoryWebhookTokenRepository {

View File

@@ -49,6 +49,12 @@ impl DiaryRepository for PanicDiaryRepository {
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!("PanicDiaryRepository called") panic!("PanicDiaryRepository called")
} }
fn stream_user_history(
&self,
_: UserId,
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>> {
panic!("PanicDiaryRepository called")
}
async fn get_movie_stats(&self, _: &MovieId) -> Result<MovieStats, DomainError> { async fn get_movie_stats(&self, _: &MovieId) -> Result<MovieStats, DomainError> {
panic!("PanicDiaryRepository called") panic!("PanicDiaryRepository called")
} }
@@ -250,13 +256,12 @@ impl PosterFetcherClient for PanicPosterFetcher {
pub struct PanicDiaryExporter; pub struct PanicDiaryExporter;
#[async_trait]
impl DiaryExporter for PanicDiaryExporter { impl DiaryExporter for PanicDiaryExporter {
async fn serialize_entries( fn stream_entries(
&self, &self,
_: &[DiaryEntry], _stream: futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>>,
_: ExportFormat, _format: ExportFormat,
) -> Result<Vec<u8>, DomainError> { ) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>> {
panic!("PanicDiaryExporter called") panic!("PanicDiaryExporter called")
} }
} }

View File

@@ -42,6 +42,7 @@ dotenvy = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
futures = { workspace = true }
api-types = { workspace = true } api-types = { workspace = true }
domain = { workspace = true, features = ["test-helpers"] } domain = { workspace = true, features = ["test-helpers"] }

View File

@@ -36,6 +36,7 @@ pub struct DatabaseOutput {
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>, pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub goal: Arc<dyn domain::ports::GoalRepository>, pub goal: Arc<dyn domain::ports::GoalRepository>,
pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>, pub user_settings: Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>, pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub db_pool: DbPool, pub db_pool: DbPool,
@@ -78,6 +79,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
wrapup_repo: w.wrapup_repo, wrapup_repo: w.wrapup_repo,
goal: w.goal, goal: w.goal,
user_settings: w.user_settings, user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal, remote_goal: w.remote_goal,
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(), w.pool.clone(),
@@ -119,6 +121,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result
wrapup_repo: w.wrapup_repo, wrapup_repo: w.wrapup_repo,
goal: w.goal, goal: w.goal,
user_settings: w.user_settings, user_settings: w.user_settings,
federation_settings: w.federation_settings,
remote_goal: w.remote_goal, remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _, as _,

View File

@@ -1,9 +1,11 @@
use axum::{ use axum::{
Form, Json, Form, Json,
body::Body,
extract::{Extension, Path, Query, State}, extract::{Extension, Path, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Redirect}, response::{IntoResponse, Redirect},
}; };
use futures::StreamExt;
use uuid::Uuid; use uuid::Uuid;
use application::diary::{ use application::diary::{
@@ -147,30 +149,29 @@ pub async fn export_diary(
user_id: user.0.value(), user_id: user.0.value(),
format, format,
}; };
match export_diary_uc::execute( let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary, &state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter, &state.app_ctx.services.diary_exporter,
query, query,
) );
.await let stream = stream.map(|r| {
{ if let Err(ref e) = r {
Ok(bytes) => ( tracing::error!("diary export stream error: {e}");
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
bytes,
)
.into_response(),
Err(e) => {
tracing::error!("export error: {:?}", e);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
} }
} r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
)
.into_response()
} }
#[utoipa::path( #[utoipa::path(
@@ -314,27 +315,29 @@ pub async fn get_export_html(
user_id: user_id.value(), user_id: user_id.value(),
format, format,
}; };
match export_diary_uc::execute( let stream = export_diary_uc::execute(
&state.app_ctx.repos.diary, &state.app_ctx.repos.diary,
&state.app_ctx.services.diary_exporter, &state.app_ctx.services.diary_exporter,
query, query,
);
let stream = stream.map(|r| {
if let Err(ref e) = r {
tracing::error!("diary export stream error: {e}");
}
r
});
(
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
Body::from_stream(stream),
) )
.await .into_response()
{
Ok(bytes) => (
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, content_type.to_string()),
(
axum::http::header::CONTENT_DISPOSITION,
format!("attachment; filename=\"{}\"", filename),
),
],
bytes,
)
.into_response(),
Err(e) => crate::errors::domain_error_response(e),
}
} }
pub async fn get_activity_feed_html( pub async fn get_activity_feed_html(

View File

@@ -176,6 +176,8 @@ pub async fn get_settings(
.await?; .await?;
Ok(Json(UserSettingsDto { Ok(Json(UserSettingsDto {
federate_goals: settings.federate_goals(), federate_goals: settings.federate_goals(),
federate_reviews: settings.federate_reviews(),
federate_watchlist: settings.federate_watchlist(),
})) }))
} }
@@ -198,6 +200,8 @@ pub async fn update_settings(
application::users::update_settings::UpdateUserSettingsCommand { application::users::update_settings::UpdateUserSettingsCommand {
user_id: user.0.value(), user_id: user.0.value(),
federate_goals: req.federate_goals, federate_goals: req.federate_goals,
federate_reviews: req.federate_reviews,
federate_watchlist: req.federate_watchlist,
}, },
) )
.await?; .await?;

View File

@@ -119,6 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
remote_goal_repo: Arc::clone(&db.remote_goal), remote_goal_repo: Arc::clone(&db.remote_goal),
local_ap_content: Arc::clone(&ap_content_repo), local_ap_content: Arc::clone(&ap_content_repo),
user_repo: Arc::clone(&db.user), user_repo: Arc::clone(&db.user),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
base_url: app_config.base_url.clone(), base_url: app_config.base_url.clone(),
allow_registration: app_config.allow_registration, allow_registration: app_config.allow_registration,
event_publisher: Arc::clone(&ep), event_publisher: Arc::clone(&ep),

View File

@@ -120,6 +120,12 @@ impl DiaryRepository for Panic {
async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> { async fn get_user_history(&self, _: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
panic!() panic!()
} }
fn stream_user_history(
&self,
_: UserId,
) -> futures::stream::BoxStream<'static, Result<DiaryEntry, DomainError>> {
panic!()
}
async fn get_movie_stats( async fn get_movie_stats(
&self, &self,
_: &MovieId, _: &MovieId,
@@ -379,14 +385,17 @@ impl domain::ports::MovieProfileRepository for Panic {
Ok(vec![]) Ok(vec![])
} }
} }
#[async_trait::async_trait]
impl domain::ports::DiaryExporter for Panic { impl domain::ports::DiaryExporter for Panic {
async fn serialize_entries( fn stream_entries(
&self, &self,
_: &[domain::models::DiaryEntry], _stream: futures::stream::BoxStream<
_: domain::models::ExportFormat, 'static,
) -> Result<Vec<u8>, domain::errors::DomainError> { Result<domain::models::DiaryEntry, domain::errors::DomainError>,
panic!() >,
_format: domain::models::ExportFormat,
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, domain::errors::DomainError>>
{
panic!("Panic DiaryExporter called")
} }
} }

View File

@@ -165,14 +165,16 @@ impl domain::ports::UserProfileFieldsRepository for PanicProfileFields {
} }
struct PanicExporter; struct PanicExporter;
#[async_trait]
impl domain::ports::DiaryExporter for PanicExporter { impl domain::ports::DiaryExporter for PanicExporter {
async fn serialize_entries( fn stream_entries(
&self, &self,
_: &[domain::models::DiaryEntry], _stream: futures::stream::BoxStream<
_: domain::models::ExportFormat, 'static,
) -> Result<Vec<u8>, DomainError> { Result<domain::models::DiaryEntry, DomainError>,
panic!() >,
_format: domain::models::ExportFormat,
) -> futures::stream::BoxStream<'static, Result<bytes::Bytes, DomainError>> {
panic!("PanicExporter::stream_entries")
} }
} }

View File

@@ -30,6 +30,7 @@ pub struct WorkerDbOutput {
pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>, pub wrapup_repo: Arc<dyn domain::ports::WrapUpRepository>,
pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>, pub remote_goal: Arc<dyn domain::ports::RemoteGoalRepository>,
pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>, pub refresh_session: Arc<dyn domain::ports::RefreshSessionRepository>,
pub federation_settings: Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub db_pool: DbPool, pub db_pool: DbPool,
} }
@@ -64,6 +65,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new(
w.pool.clone(), w.pool.clone(),
)) as _, )) as _,
federation_settings: w.federation_settings,
db_pool: DbPool::Postgres(w.pool), db_pool: DbPool::Postgres(w.pool),
}) })
} }
@@ -95,6 +97,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<Worker
remote_goal: w.remote_goal, remote_goal: w.remote_goal,
refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone()))
as _, as _,
federation_settings: w.federation_settings,
db_pool: DbPool::Sqlite(w.pool), db_pool: DbPool::Sqlite(w.pool),
}) })
} }

View File

@@ -241,6 +241,7 @@ async fn main() -> anyhow::Result<()> {
base_url, base_url,
allow_registration, allow_registration,
event_publisher: Arc::clone(&event_publisher), event_publisher: Arc::clone(&event_publisher),
federation_settings: std::sync::Arc::clone(&db.federation_settings),
}) })
.await?; .await?;

BIN
screenshots/feed.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
screenshots/movie.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
screenshots/person.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
screenshots/profile.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@@ -18,11 +18,15 @@ export type UpdateGoalRequest = {
export const userSettingsDtoSchema = z.object({ export const userSettingsDtoSchema = z.object({
federate_goals: z.boolean(), federate_goals: z.boolean(),
federate_reviews: z.boolean(),
federate_watchlist: z.boolean(),
}) })
export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema> export type UserSettingsDto = z.infer<typeof userSettingsDtoSchema>
export type UpdateUserSettingsRequest = { export type UpdateUserSettingsRequest = {
federate_goals: boolean federate_goals: boolean
federate_reviews: boolean
federate_watchlist: boolean
} }
export function getGoals() { export function getGoals() {

View File

@@ -178,6 +178,10 @@
"privacy": "Privacy", "privacy": "Privacy",
"federateGoals": "Share goals on Fediverse", "federateGoals": "Share goals on Fediverse",
"federateGoalsDesc": "Broadcast goal progress to followers", "federateGoalsDesc": "Broadcast goal progress to followers",
"federateReviews": "Share reviews on Fediverse",
"federateReviewsDesc": "Broadcast diary entries to followers",
"federateWatchlist": "Share watchlist on Fediverse",
"federateWatchlistDesc": "Broadcast watchlist additions to followers",
"export": "Export", "export": "Export",
"exportDesc": "Download your diary", "exportDesc": "Download your diary",
"exportCsv": "CSV", "exportCsv": "CSV",

View File

@@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next"
import { useMutation } from "@tanstack/react-query" import { useMutation } from "@tanstack/react-query"
import { import {
ArrowLeft, ArrowLeft,
BookOpen,
ChevronRight, ChevronRight,
Download, Download,
Key, Key,
List,
LogOut, LogOut,
RefreshCw, RefreshCw,
ShieldBan, ShieldBan,
@@ -19,6 +21,7 @@ import { Switch } from "@/components/ui/switch"
import { useAuth, useIsAdmin } from "@/components/auth-provider" import { useAuth, useIsAdmin } from "@/components/auth-provider"
import { reindexSearch } from "@/lib/api/users" import { reindexSearch } from "@/lib/api/users"
import { useSettings, useUpdateSettings } from "@/hooks/use-goals" import { useSettings, useUpdateSettings } from "@/hooks/use-goals"
import type { UpdateUserSettingsRequest } from "@/lib/api/goals"
import { useDocumentTitle } from "@/hooks/use-document-title" import { useDocumentTitle } from "@/hooks/use-document-title"
export const Route = createFileRoute("/_app/settings/")({ export const Route = createFileRoute("/_app/settings/")({
@@ -128,6 +131,18 @@ function PrivacySection() {
const { data: settings } = useSettings() const { data: settings } = useSettings()
const updateMutation = useUpdateSettings() const updateMutation = useUpdateSettings()
const disabled = updateMutation.isPending
const toggle = (patch: Partial<UpdateUserSettingsRequest>) => {
if (!settings) return
updateMutation.mutate({
federate_goals: settings.federate_goals,
federate_reviews: settings.federate_reviews,
federate_watchlist: settings.federate_watchlist,
...patch,
})
}
return ( return (
<div> <div>
<p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground"> <p className="mb-1.5 px-1 text-xs font-medium text-muted-foreground">
@@ -145,11 +160,41 @@ function PrivacySection() {
</p> </p>
</div> </div>
<Switch <Switch
checked={settings?.federate_goals ?? false} checked={settings?.federate_goals ?? true}
onCheckedChange={(checked) => onCheckedChange={(checked) => toggle({ federate_goals: checked })}
updateMutation.mutate({ federate_goals: checked }) disabled={disabled}
} />
disabled={updateMutation.isPending} </div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<BookOpen className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateReviews")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateReviewsDesc")}
</p>
</div>
<Switch
checked={settings?.federate_reviews ?? true}
onCheckedChange={(checked) => toggle({ federate_reviews: checked })}
disabled={disabled}
/>
</div>
<div className="flex items-center gap-3 p-3">
<span className="text-muted-foreground">
<List className="size-4" />
</span>
<div className="flex-1">
<p className="text-sm font-medium">{t("settings.federateWatchlist")}</p>
<p className="text-xs text-muted-foreground">
{t("settings.federateWatchlistDesc")}
</p>
</div>
<Switch
checked={settings?.federate_watchlist ?? true}
onCheckedChange={(checked) => toggle({ federate_watchlist: checked })}
disabled={disabled}
/> />
</div> </div>
</div> </div>