From 933400e1c2920b69cb2c240764c4b292ce31cfe1 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Sun, 25 Oct 2026 20:34:07 +0100 Subject: [PATCH] refactor: drop postgres backend --- Cargo.lock | 91 --- Cargo.toml | 10 - .../adapters/postgres-event-queue/Cargo.toml | 15 - .../migrations/0100_event_queue.sql | 13 - .../adapters/postgres-event-queue/src/lib.rs | 233 ------- .../postgres-event-queue/src/migrations.rs | 7 - .../postgres-event-queue/src/payload.rs | 1 - .../adapters/postgres-federation/Cargo.toml | 24 - .../postgres-federation/src/activity.rs | 30 - .../adapters/postgres-federation/src/actor.rs | 102 ---- .../postgres-federation/src/blocklist.rs | 91 --- .../src/follow/followers.rs | 197 ------ .../src/follow/following.rs | 106 ---- .../src/follow/migration.rs | 27 - .../postgres-federation/src/follow/mod.rs | 3 - .../adapters/postgres-federation/src/lib.rs | 85 --- .../postgres-federation/src/review.rs | 112 ---- crates/adapters/postgres-search/Cargo.toml | 11 - crates/adapters/postgres-search/src/lib.rs | 353 ----------- crates/adapters/postgres-social/Cargo.toml | 18 - .../postgres-social/src/ap_content.rs | 301 --------- .../postgres-social/src/federated_profile.rs | 58 -- .../postgres-social/src/follow_repository.rs | 354 ----------- crates/adapters/postgres-social/src/lib.rs | 30 - .../postgres-social/src/remote_goals.rs | 114 ---- crates/adapters/postgres-social/src/social.rs | 21 - .../adapters/postgres-social/src/watchlist.rs | 91 --- crates/adapters/postgres/Cargo.toml | 27 - .../postgres/migrations/0001_initial.sql | 69 --- .../postgres/migrations/0002_import.sql | 21 - .../postgres/migrations/0009_user_profile.sql | 2 - .../0010_ap_remote_actor_avatar.sql | 1 - .../postgres/migrations/0011_ap_announces.sql | 8 - .../migrations/0012_blocked_domains.sql | 5 - .../migrations/0013_blocked_actors.sql | 6 - .../migrations/0014_movie_profiles.sql | 54 -- .../postgres/migrations/0015_search.sql | 25 - .../postgres/migrations/0016_watchlist.sql | 9 - .../migrations/0017_ap_remote_watchlist.sql | 12 - .../0018_remote_actor_outbox_url.sql | 1 - .../migrations/0019_profile_extended.sql | 13 - .../postgres/migrations/0020_kap_v03.sql | 12 - .../migrations/0021_user_display_name.sql | 1 - .../migrations/0022_instance_actor.sql | 10 - .../postgres/migrations/0023_watch_events.sql | 28 - .../postgres/migrations/0024_wrap_up.sql | 12 - .../postgres/migrations/0025_goals.sql | 25 - .../migrations/0026_person_enrichment.sql | 8 - .../migrations/0027_refresh_sessions.sql | 10 - .../migrations/0028_federation_privacy.sql | 2 - .../postgres/migrations/0029_watch_medium.sql | 1 - .../migrations/0030_review_spoilers.sql | 1 - crates/adapters/postgres/src/diary.rs | 570 ------------------ crates/adapters/postgres/src/goals.rs | 189 ------ crates/adapters/postgres/src/image_ref.rs | 66 -- .../adapters/postgres/src/import_profile.rs | 212 ------- .../adapters/postgres/src/import_session.rs | 387 ------------ crates/adapters/postgres/src/insights.rs | 81 --- crates/adapters/postgres/src/lib.rs | 127 ---- crates/adapters/postgres/src/models.rs | 292 --------- crates/adapters/postgres/src/movie.rs | 253 -------- crates/adapters/postgres/src/movie_dedup.rs | 147 ----- crates/adapters/postgres/src/persons.rs | 339 ----------- crates/adapters/postgres/src/profile.rs | 284 --------- .../adapters/postgres/src/profile_fields.rs | 74 --- .../adapters/postgres/src/refresh_sessions.rs | 108 ---- crates/adapters/postgres/src/review.rs | 130 ---- crates/adapters/postgres/src/stats.rs | 239 -------- crates/adapters/postgres/src/user_settings.rs | 99 --- crates/adapters/postgres/src/users.rs | 200 ------ crates/adapters/postgres/src/watch_event.rs | 384 ------------ crates/adapters/postgres/src/watchlist.rs | 189 ------ crates/adapters/postgres/src/wrapup.rs | 508 ---------------- crates/composition/Cargo.toml | 7 - crates/composition/src/factory.rs | 169 ++---- crates/infra-wiring/Cargo.toml | 1 - crates/infra-wiring/src/lib.rs | 2 - crates/server/Cargo.toml | 14 - crates/server/src/main.rs | 41 +- crates/worker/Cargo.toml | 12 - crates/worker/src/event_bus.rs | 4 - crates/worker/src/main.rs | 25 +- 82 files changed, 77 insertions(+), 7937 deletions(-) delete mode 100644 crates/adapters/postgres-event-queue/Cargo.toml delete mode 100644 crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql delete mode 100644 crates/adapters/postgres-event-queue/src/lib.rs delete mode 100644 crates/adapters/postgres-event-queue/src/migrations.rs delete mode 100644 crates/adapters/postgres-event-queue/src/payload.rs delete mode 100644 crates/adapters/postgres-federation/Cargo.toml delete mode 100644 crates/adapters/postgres-federation/src/activity.rs delete mode 100644 crates/adapters/postgres-federation/src/actor.rs delete mode 100644 crates/adapters/postgres-federation/src/blocklist.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/followers.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/following.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/migration.rs delete mode 100644 crates/adapters/postgres-federation/src/follow/mod.rs delete mode 100644 crates/adapters/postgres-federation/src/lib.rs delete mode 100644 crates/adapters/postgres-federation/src/review.rs delete mode 100644 crates/adapters/postgres-search/Cargo.toml delete mode 100644 crates/adapters/postgres-search/src/lib.rs delete mode 100644 crates/adapters/postgres-social/Cargo.toml delete mode 100644 crates/adapters/postgres-social/src/ap_content.rs delete mode 100644 crates/adapters/postgres-social/src/federated_profile.rs delete mode 100644 crates/adapters/postgres-social/src/follow_repository.rs delete mode 100644 crates/adapters/postgres-social/src/lib.rs delete mode 100644 crates/adapters/postgres-social/src/remote_goals.rs delete mode 100644 crates/adapters/postgres-social/src/social.rs delete mode 100644 crates/adapters/postgres-social/src/watchlist.rs delete mode 100644 crates/adapters/postgres/Cargo.toml delete mode 100644 crates/adapters/postgres/migrations/0001_initial.sql delete mode 100644 crates/adapters/postgres/migrations/0002_import.sql delete mode 100644 crates/adapters/postgres/migrations/0009_user_profile.sql delete mode 100644 crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql delete mode 100644 crates/adapters/postgres/migrations/0011_ap_announces.sql delete mode 100644 crates/adapters/postgres/migrations/0012_blocked_domains.sql delete mode 100644 crates/adapters/postgres/migrations/0013_blocked_actors.sql delete mode 100644 crates/adapters/postgres/migrations/0014_movie_profiles.sql delete mode 100644 crates/adapters/postgres/migrations/0015_search.sql delete mode 100644 crates/adapters/postgres/migrations/0016_watchlist.sql delete mode 100644 crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql delete mode 100644 crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql delete mode 100644 crates/adapters/postgres/migrations/0019_profile_extended.sql delete mode 100644 crates/adapters/postgres/migrations/0020_kap_v03.sql delete mode 100644 crates/adapters/postgres/migrations/0021_user_display_name.sql delete mode 100644 crates/adapters/postgres/migrations/0022_instance_actor.sql delete mode 100644 crates/adapters/postgres/migrations/0023_watch_events.sql delete mode 100644 crates/adapters/postgres/migrations/0024_wrap_up.sql delete mode 100644 crates/adapters/postgres/migrations/0025_goals.sql delete mode 100644 crates/adapters/postgres/migrations/0026_person_enrichment.sql delete mode 100644 crates/adapters/postgres/migrations/0027_refresh_sessions.sql delete mode 100644 crates/adapters/postgres/migrations/0028_federation_privacy.sql delete mode 100644 crates/adapters/postgres/migrations/0029_watch_medium.sql delete mode 100644 crates/adapters/postgres/migrations/0030_review_spoilers.sql delete mode 100644 crates/adapters/postgres/src/diary.rs delete mode 100644 crates/adapters/postgres/src/goals.rs delete mode 100644 crates/adapters/postgres/src/image_ref.rs delete mode 100644 crates/adapters/postgres/src/import_profile.rs delete mode 100644 crates/adapters/postgres/src/import_session.rs delete mode 100644 crates/adapters/postgres/src/insights.rs delete mode 100644 crates/adapters/postgres/src/lib.rs delete mode 100644 crates/adapters/postgres/src/models.rs delete mode 100644 crates/adapters/postgres/src/movie.rs delete mode 100644 crates/adapters/postgres/src/movie_dedup.rs delete mode 100644 crates/adapters/postgres/src/persons.rs delete mode 100644 crates/adapters/postgres/src/profile.rs delete mode 100644 crates/adapters/postgres/src/profile_fields.rs delete mode 100644 crates/adapters/postgres/src/refresh_sessions.rs delete mode 100644 crates/adapters/postgres/src/review.rs delete mode 100644 crates/adapters/postgres/src/stats.rs delete mode 100644 crates/adapters/postgres/src/user_settings.rs delete mode 100644 crates/adapters/postgres/src/users.rs delete mode 100644 crates/adapters/postgres/src/watch_event.rs delete mode 100644 crates/adapters/postgres/src/watchlist.rs delete mode 100644 crates/adapters/postgres/src/wrapup.rs diff --git a/Cargo.lock b/Cargo.lock index b4736427..839c63fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,11 +1147,6 @@ dependencies = [ "object-storage", "plex", "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", "sqlite", "sqlite-event-queue", "sqlite-federation", @@ -3956,83 +3951,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "postgres" -version = "0.1.0" -dependencies = [ - "adapter-common", - "anyhow", - "async-stream", - "async-trait", - "bytes", - "chrono", - "domain", - "futures", - "postgres-social", - "serde", - "serde_json", - "sqlx", - "tokio", - "tracing", - "uuid", -] - -[[package]] -name = "postgres-event-queue" -version = "0.1.0" -dependencies = [ - "anyhow", - "async-trait", - "domain", - "event-payload", - "futures", - "serde_json", - "sqlx", - "tokio", - "tracing", -] - -[[package]] -name = "postgres-federation" -version = "0.1.0" -dependencies = [ - "activitypub", - "adapter-common", - "anyhow", - "async-trait", - "chrono", - "domain", - "k-ap", - "postgres-social", - "serde_json", - "sqlx", - "tracing", - "uuid", -] - -[[package]] -name = "postgres-search" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "domain", - "sqlx", - "uuid", -] - -[[package]] -name = "postgres-social" -version = "0.1.0" -dependencies = [ - "adapter-common", - "async-trait", - "chrono", - "domain", - "sqlx", - "uuid", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -5027,11 +4945,6 @@ dependencies = [ "nats", "object-storage", "poster-fetcher", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", - "postgres-social", "presentation", "rss 0.1.0", "serde_json", @@ -7289,10 +7202,6 @@ dependencies = [ "object-storage", "poster-fetcher", "poster-sync", - "postgres", - "postgres-event-queue", - "postgres-federation", - "postgres-search", "sqlite", "sqlite-event-queue", "sqlite-federation", diff --git a/Cargo.toml b/Cargo.toml index 4c4c940a..a718d83e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,13 +8,9 @@ members = [ "crates/adapters/poster-sync", "crates/adapters/rss", "crates/adapters/sqlite", - "crates/adapters/postgres", "crates/adapters/sqlite-federation", "crates/adapters/sqlite-social", - "crates/adapters/postgres-federation", - "crates/adapters/postgres-social", "crates/adapters/sqlite-event-queue", - "crates/adapters/postgres-event-queue", "crates/adapters/template-askama", "crates/adapters/activitypub", "crates/adapters/export", @@ -34,7 +30,6 @@ members = [ "crates/adapters/jellyfin", "crates/adapters/plex", "crates/adapters/sqlite-search", - "crates/adapters/postgres-search", "crates/adapters/adapter-common", "crates/infra-wiring", ] @@ -89,21 +84,16 @@ export = { path = "crates/adapters/export" } sqlite = { path = "crates/adapters/sqlite" } sqlite-federation = { path = "crates/adapters/sqlite-federation" } sqlite-social = { path = "crates/adapters/sqlite-social" } -postgres = { path = "crates/adapters/postgres" } -postgres-federation = { path = "crates/adapters/postgres-federation" } -postgres-social = { path = "crates/adapters/postgres-social" } template-askama = { path = "crates/adapters/template-askama" } activitypub = { path = "crates/adapters/activitypub" } event-payload = { path = "crates/adapters/event-payload" } nats = { path = "crates/adapters/nats" } sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" } -postgres-event-queue = { path = "crates/adapters/postgres-event-queue" } importer = { path = "crates/adapters/importer" } jellyfin = { path = "crates/adapters/jellyfin" } plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } sqlite-search = { path = "crates/adapters/sqlite-search" } -postgres-search = { path = "crates/adapters/postgres-search" } adapter-common = { path = "crates/adapters/adapter-common" } infra-wiring = { path = "crates/infra-wiring" } diff --git a/crates/adapters/postgres-event-queue/Cargo.toml b/crates/adapters/postgres-event-queue/Cargo.toml deleted file mode 100644 index 83f23d11..00000000 --- a/crates/adapters/postgres-event-queue/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "postgres-event-queue" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "macros", "chrono"] } -domain = { workspace = true } -event-payload = { workspace = true } -anyhow = { workspace = true } -async-trait = { workspace = true } -serde_json = { workspace = true } -tokio = { workspace = true } -futures = { workspace = true } -tracing = { workspace = true } diff --git a/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql b/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql deleted file mode 100644 index 33dc8580..00000000 --- a/crates/adapters/postgres-event-queue/migrations/0100_event_queue.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE TABLE IF NOT EXISTS event_queue ( - id BIGSERIAL PRIMARY KEY, - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - attempts INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_error TEXT -); - -CREATE INDEX IF NOT EXISTS idx_event_queue_poll - ON event_queue (status, next_attempt_at); diff --git a/crates/adapters/postgres-event-queue/src/lib.rs b/crates/adapters/postgres-event-queue/src/lib.rs deleted file mode 100644 index b2e62d7b..00000000 --- a/crates/adapters/postgres-event-queue/src/lib.rs +++ /dev/null @@ -1,233 +0,0 @@ -mod migrations; -mod payload; - -use std::sync::Arc; -use std::time::Duration; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - events::{AckHandle, DomainEvent, EventEnvelope}, - ports::{EventConsumer, EventPublisher}, -}; -use futures::stream::{self, BoxStream}; -use sqlx::PgPool; -use tokio::sync::{Mutex, mpsc}; - -use payload::DbEventPayload; - -pub struct DbEventQueueConfig { - pub poll_interval_ms: u64, - pub batch_size: i64, - pub max_attempts: i32, -} - -impl DbEventQueueConfig { - pub fn from_env() -> Self { - Self { - poll_interval_ms: std::env::var("EVENT_QUEUE_POLL_INTERVAL_MS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(500), - batch_size: std::env::var("EVENT_QUEUE_BATCH_SIZE") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(10), - max_attempts: std::env::var("EVENT_QUEUE_MAX_ATTEMPTS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5), - } - } -} - -#[derive(Clone)] -pub struct PostgresEventQueue { - pool: PgPool, - config: Arc, -} - -impl PostgresEventQueue { - pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result { - migrations::run(&pool).await?; - Ok(Self { - pool, - config: Arc::new(config), - }) - } - - pub async fn create_publisher(pool: PgPool) -> anyhow::Result> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; - Ok(Arc::new(q)) - } - - pub async fn create_channel( - pool: PgPool, - ) -> anyhow::Result<(Arc, Arc)> { - let q = Self::create(pool, DbEventQueueConfig::from_env()).await?; - Ok((Arc::new(q.clone()), Arc::new(q))) - } -} - -#[async_trait] -impl EventPublisher for PostgresEventQueue { - async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { - let db_payload = DbEventPayload::from(event); - let event_type = db_payload.event_type(); - let payload_json = serde_json::to_string(&db_payload) - .map_err(|e| DomainError::InfrastructureError(format!("serialize: {e}")))?; - - sqlx::query("INSERT INTO event_queue (event_type, payload) VALUES ($1, $2)") - .bind(event_type) - .bind(payload_json) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("insert event: {e}")))?; - - Ok(()) - } -} - -impl EventConsumer for PostgresEventQueue { - fn consume(&self) -> BoxStream<'_, Result> { - let pool = self.pool.clone(); - let config = Arc::clone(&self.config); - let (tx, rx) = mpsc::channel(128); - let rx = Arc::new(Mutex::new(rx)); - - tokio::spawn(async move { - let poll_interval = Duration::from_millis(config.poll_interval_ms); - loop { - match claim_batch(&pool, &config).await { - Err(e) => { - tracing::error!("postgres event queue claim error: {e}"); - tokio::time::sleep(poll_interval).await; - } - Ok(rows) if rows.is_empty() => { - tokio::time::sleep(poll_interval).await; - } - Ok(rows) => { - for row in rows { - let envelope = decode_row(&pool, row, config.max_attempts); - if tx.send(envelope).await.is_err() { - tracing::info!("postgres event queue consumer closed"); - return; - } - } - } - } - } - }); - - Box::pin(stream::unfold(rx, |rx| async move { - let item = rx.lock().await.recv().await?; - Some((item, rx)) - })) - } -} - -// ── Internal types ──────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct QueueRow { - id: i64, - payload: String, - attempts: i32, -} - -async fn claim_batch( - pool: &PgPool, - config: &DbEventQueueConfig, -) -> Result, DomainError> { - let rows = sqlx::query_as::<_, QueueRow>( - r#" - WITH claimed AS ( - SELECT id FROM event_queue - WHERE status = 'pending' AND next_attempt_at <= NOW() - ORDER BY next_attempt_at ASC - LIMIT $1 - FOR UPDATE SKIP LOCKED - ) - UPDATE event_queue q - SET status = 'processing' - FROM claimed - WHERE q.id = claimed.id - RETURNING q.id, q.payload, q.attempts - "#, - ) - .bind(config.batch_size) - .fetch_all(pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("claim batch: {e}")))?; - - Ok(rows) -} - -fn decode_row( - pool: &PgPool, - row: QueueRow, - max_attempts: i32, -) -> Result { - let db_payload: DbEventPayload = serde_json::from_str(&row.payload) - .map_err(|e| DomainError::InfrastructureError(format!("deserialize: {e}")))?; - let event = DomainEvent::try_from(db_payload)?; - Ok(EventEnvelope::new( - event, - Box::new(DbAckHandle { - pool: pool.clone(), - row_id: row.id, - attempts: row.attempts, - max_attempts, - }), - )) -} - -struct DbAckHandle { - pool: PgPool, - row_id: i64, - attempts: i32, - max_attempts: i32, -} - -#[async_trait] -impl AckHandle for DbAckHandle { - async fn ack(&self) -> Result<(), DomainError> { - sqlx::query("UPDATE event_queue SET status = 'done' WHERE id = $1") - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("ack: {e}")))?; - Ok(()) - } - - async fn nack(&self) -> Result<(), DomainError> { - let new_attempts = self.attempts + 1; - if new_attempts >= self.max_attempts { - sqlx::query( - "UPDATE event_queue SET status = 'dead_lettered', attempts = $1, last_error = 'max attempts reached' WHERE id = $2" - ) - .bind(new_attempts) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("nack dead-letter: {e}")))?; - } else { - let backoff = backoff_seconds(new_attempts).to_string(); - sqlx::query( - "UPDATE event_queue SET status = 'pending', attempts = $1, next_attempt_at = NOW() + ($2 || ' seconds')::interval, last_error = 'nack' WHERE id = $3" - ) - .bind(new_attempts) - .bind(backoff) - .bind(self.row_id) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(format!("nack retry: {e}")))?; - } - Ok(()) - } -} - -fn backoff_seconds(attempts: i32) -> i64 { - let base: i64 = 5 * (1i64 << attempts.min(6)); - base.min(300) -} diff --git a/crates/adapters/postgres-event-queue/src/migrations.rs b/crates/adapters/postgres-event-queue/src/migrations.rs deleted file mode 100644 index ef49936f..00000000 --- a/crates/adapters/postgres-event-queue/src/migrations.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub(crate) async fn run(pool: &sqlx::PgPool) -> anyhow::Result<()> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| anyhow::anyhow!("postgres-event-queue migration failed: {e}")) -} diff --git a/crates/adapters/postgres-event-queue/src/payload.rs b/crates/adapters/postgres-event-queue/src/payload.rs deleted file mode 100644 index 6df07ad8..00000000 --- a/crates/adapters/postgres-event-queue/src/payload.rs +++ /dev/null @@ -1 +0,0 @@ -pub use event_payload::EventPayload as DbEventPayload; diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml deleted file mode 100644 index a7bd6358..00000000 --- a/crates/adapters/postgres-federation/Cargo.toml +++ /dev/null @@ -1,24 +0,0 @@ -[package] -name = "postgres-federation" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -activitypub = { workspace = true } -adapter-common = { workspace = true } -k-ap = { version = "0.5.0", registry = "gitea" } -postgres-social = { workspace = true } -domain = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -async-trait = { workspace = true } -anyhow = { workspace = true } -serde_json = { workspace = true } diff --git a/crates/adapters/postgres-federation/src/activity.rs b/crates/adapters/postgres-federation/src/activity.rs deleted file mode 100644 index a7437e32..00000000 --- a/crates/adapters/postgres-federation/src/activity.rs +++ /dev/null @@ -1,30 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::ActivityRepository; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl ActivityRepository for PostgresFederationRepository { - async fn is_activity_processed(&self, activity_id: &str) -> Result { - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ap_activities WHERE id = $1") - .bind(activity_id) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } - - async fn mark_activity_processed(&self, activity_id: &str) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_activities (id, processed_at) VALUES ($1, $2) ON CONFLICT DO NOTHING", - ) - .bind(activity_id) - .bind(&ts) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/crates/adapters/postgres-federation/src/actor.rs b/crates/adapters/postgres-federation/src/actor.rs deleted file mode 100644 index 41a0e289..00000000 --- a/crates/adapters/postgres-federation/src/actor.rs +++ /dev/null @@ -1,102 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{AnnounceRepository, Keypair, KeypairRepository, RemoteActor, RemoteActorCache}; -use sqlx::Row; - -use super::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor}; -use adapter_common::datetime_to_str; - -#[async_trait] -impl KeypairRepository for PostgresFederationRepository { - async fn get_local_actor_keypair(&self, user_id: uuid::Uuid) -> Result> { - let uid = user_id.to_string(); - let row = - sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await?; - Ok(row.map(|r| Keypair { - public_key: r.get("public_key"), - private_key: r.get("private_key"), - })) - } - - async fn save_local_actor_keypair(&self, user_id: uuid::Uuid, keypair: Keypair) -> Result<()> { - let uid = user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_local_actors (user_id, public_key, private_key, created_at) VALUES ($1, $2, $3, $4::timestamptz) - ON CONFLICT(user_id) DO UPDATE SET public_key = EXCLUDED.public_key, private_key = EXCLUDED.private_key", - ).bind(&uid).bind(&keypair.public_key).bind(&keypair.private_key).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } -} - -#[async_trait] -impl RemoteActorCache for PostgresFederationRepository { - async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> { - let now = Utc::now().naive_utc(); - let fetched_at = datetime_to_str(&now); - let aka_json = serde_json::to_string(&actor.also_known_as).unwrap_or_default(); - sqlx::query( - "INSERT INTO ap_remote_actors (url, handle, inbox_url, shared_inbox_url, display_name, avatar_url, outbox_url, bio, banner_url, followers_url, following_url, also_known_as, fetched_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz) - ON CONFLICT(url) DO UPDATE SET - handle=EXCLUDED.handle, inbox_url=EXCLUDED.inbox_url, shared_inbox_url=EXCLUDED.shared_inbox_url, - display_name=EXCLUDED.display_name, avatar_url=EXCLUDED.avatar_url, - outbox_url=COALESCE(EXCLUDED.outbox_url, ap_remote_actors.outbox_url), - bio=EXCLUDED.bio, banner_url=EXCLUDED.banner_url, followers_url=EXCLUDED.followers_url, - following_url=EXCLUDED.following_url, also_known_as=EXCLUDED.also_known_as, fetched_at=EXCLUDED.fetched_at", - ) - .bind(&actor.url).bind(&actor.handle).bind(&actor.inbox_url).bind(&actor.shared_inbox_url) - .bind(&actor.display_name).bind(&actor.avatar_url).bind(&actor.outbox_url) - .bind(&actor.bio).bind(&actor.banner_url).bind(&actor.followers_url).bind(&actor.following_url) - .bind(&aka_json).bind(&fetched_at) - .execute(&self.pool).await?; - Ok(()) - } - - async fn get_remote_actor(&self, actor_url: &str) -> Result> { - let q = format!("SELECT url, {PG_ACTOR_COLS} FROM ap_remote_actors a WHERE url = $1"); - let row = sqlx::query(&q) - .bind(actor_url) - .fetch_optional(&self.pool) - .await?; - Ok(row.as_ref().map(|r| pg_remote_actor(r, "url"))) - } -} - -#[async_trait] -impl AnnounceRepository for PostgresFederationRepository { - async fn add_announce( - &self, - activity_id: &str, - object_url: &str, - actor_url: &str, - announced_at: chrono::DateTime, - ) -> Result<()> { - let ts = announced_at.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query("INSERT INTO ap_announces (id, object_url, actor_url, announced_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING") - .bind(activity_id).bind(object_url).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_announce(&self, activity_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM ap_announces WHERE id = $1 AND actor_url = $2") - .bind(activity_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn count_announces(&self, object_url: &str) -> Result { - let row = sqlx::query("SELECT COUNT(*) as cnt FROM ap_announces WHERE object_url = $1") - .bind(object_url) - .fetch_one(&self.pool) - .await?; - Ok(row.get::("cnt") as usize) - } -} diff --git a/crates/adapters/postgres-federation/src/blocklist.rs b/crates/adapters/postgres-federation/src/blocklist.rs deleted file mode 100644 index 0bb22a3e..00000000 --- a/crates/adapters/postgres-federation/src/blocklist.rs +++ /dev/null @@ -1,91 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{ActorBlocklist, BlockedDomain, DomainBlocklist}; -use sqlx::Row; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl DomainBlocklist for PostgresFederationRepository { - async fn add_blocked_domain(&self, domain: &str, reason: Option<&str>) -> Result<()> { - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_domains (domain, reason, blocked_at) VALUES ($1, $2, $3) ON CONFLICT(domain) DO UPDATE SET reason = EXCLUDED.reason") - .bind(domain).bind(reason).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_blocked_domain(&self, domain: &str) -> Result<()> { - sqlx::query("DELETE FROM blocked_domains WHERE domain = $1") - .bind(domain) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_domains(&self) -> Result> { - let rows = sqlx::query( - "SELECT domain, reason, blocked_at FROM blocked_domains ORDER BY blocked_at DESC", - ) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|r| BlockedDomain { - domain: r.get("domain"), - reason: r.get("reason"), - blocked_at: r.get("blocked_at"), - }) - .collect()) - } - - async fn is_domain_blocked(&self, domain: &str) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM blocked_domains WHERE domain = $1") - .bind(domain) - .fetch_one(&self.pool) - .await?; - Ok(count > 0) - } -} - -#[async_trait] -impl ActorBlocklist for PostgresFederationRepository { - async fn add_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - let ts = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query("INSERT INTO blocked_actors (local_user_id, remote_actor_url, blocked_at) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING") - .bind(&uid).bind(actor_url).bind(&ts).execute(&self.pool).await?; - Ok(()) - } - - async fn remove_blocked_actor(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query( - "DELETE FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2", - ) - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn get_blocked_actors(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query("SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1 ORDER BY blocked_at DESC") - .bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|r| r.get::("remote_actor_url")) - .collect()) - } - - async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM blocked_actors WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid).bind(actor_url).fetch_one(&self.pool).await?; - Ok(count > 0) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/followers.rs b/crates/adapters/postgres-federation/src/follow/followers.rs deleted file mode 100644 index 0a1e6548..00000000 --- a/crates/adapters/postgres-federation/src/follow/followers.rs +++ /dev/null @@ -1,197 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{Follower, FollowerReader, FollowerStatus, FollowerWriter, RemoteActor}; -use sqlx::Row; - -use crate::{ - PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor, status_to_str, str_to_status, -}; -use adapter_common::datetime_to_str; - -#[async_trait] -impl FollowerWriter for PostgresFederationRepository { - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES ($1, $2, $3, $4::timestamptz, $5) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET - status = EXCLUDED.status, follow_activity_id = EXCLUDED.follow_activity_id", - ).bind(&uid).bind(remote_actor_url).bind(status_str).bind(&created_at).bind(follow_activity_id).execute(&self.pool).await?; - Ok(()) - } - - async fn get_follower_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar( - "SELECT follow_activity_id FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2", - ).bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row) - } - - async fn remove_follower( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(remote_actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowerStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = status_to_str(&status); - let result = sqlx::query("UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") - .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_follower_status: no row found"); - } - Ok(()) - } -} - -#[async_trait] -impl FollowerReader for PostgresFederationRepository { - async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn get_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, f.status, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| { - let status_str: String = row.get("status"); - Follower { - actor: pg_remote_actor(row, "remote_actor_url"), - status: str_to_status(&status_str), - } - }) - .collect()) - } - - async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_pending_followers(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'pending'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } - - async fn get_accepted_follower_inboxes( - &self, - local_user_id: uuid::Uuid, - ) -> Result> { - let uid = local_user_id.to_string(); - let rows = sqlx::query( - "SELECT DISTINCT COALESCE(a.shared_inbox_url, a.inbox_url) as inbox - FROM ap_followers f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $1 AND f.status = 'accepted' - AND f.remote_actor_url NOT IN (SELECT remote_actor_url FROM blocked_actors WHERE local_user_id = $1)", - ).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows - .iter() - .filter_map(|r| r.try_get::("inbox").ok()) - .collect()) - } - - async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_accepted_followers_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT f.remote_actor_url, {PG_ACTOR_COLS} FROM ap_followers f LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows - .iter() - .map(|row| pg_remote_actor(row, "remote_actor_url")) - .collect()) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/following.rs b/crates/adapters/postgres-federation/src/follow/following.rs deleted file mode 100644 index 795487b5..00000000 --- a/crates/adapters/postgres-federation/src/follow/following.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::{PG_ACTOR_COLS, PostgresFederationRepository, pg_remote_actor}; -use adapter_common::datetime_to_str; -use anyhow::Result; -use async_trait::async_trait; -use chrono::Utc; -use k_ap::{FollowingReader, FollowingStatus, FollowingWriter, RemoteActor, RemoteActorCache}; - -#[async_trait] -impl FollowingWriter for PostgresFederationRepository { - async fn add_following( - &self, - local_user_id: uuid::Uuid, - actor: RemoteActor, - follow_activity_id: &str, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let now = Utc::now().naive_utc(); - let created_at = datetime_to_str(&now); - RemoteActorCache::upsert_remote_actor(self, actor.clone()).await?; - sqlx::query("INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at) VALUES ($1, $2, $3, $4::timestamptz) ON CONFLICT DO NOTHING") - .bind(&uid).bind(&actor.url).bind(follow_activity_id).bind(&created_at).execute(&self.pool).await?; - Ok(()) - } - - async fn get_follow_activity_id( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - ) -> Result> { - let uid = local_user_id.to_string(); - let row: Option = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?; - Ok(row) - } - - async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_following_status( - &self, - local_user_id: uuid::Uuid, - remote_actor_url: &str, - status: FollowingStatus, - ) -> Result<()> { - let uid = local_user_id.to_string(); - let status_str = match status { - FollowingStatus::Pending => "pending", - FollowingStatus::Accepted => "accepted", - }; - let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3") - .bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?; - if result.rows_affected() == 0 { - tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found"); - } - Ok(()) - } -} - -#[async_trait] -impl FollowingReader for PostgresFederationRepository { - async fn get_following(&self, local_user_id: uuid::Uuid) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'" - ); - let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } - - async fn count_following(&self, local_user_id: uuid::Uuid) -> Result { - let uid = local_user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await?; - Ok(count as usize) - } - - async fn get_following_page( - &self, - local_user_id: uuid::Uuid, - offset: u32, - limit: usize, - ) -> Result> { - let uid = local_user_id.to_string(); - let q = format!( - "SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3" - ); - let rows = sqlx::query(&q) - .bind(&uid) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await?; - Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect()) - } -} diff --git a/crates/adapters/postgres-federation/src/follow/migration.rs b/crates/adapters/postgres-federation/src/follow/migration.rs deleted file mode 100644 index 7c990e07..00000000 --- a/crates/adapters/postgres-federation/src/follow/migration.rs +++ /dev/null @@ -1,27 +0,0 @@ -use anyhow::Result; -use async_trait::async_trait; -use k_ap::FollowMigration; - -use crate::PostgresFederationRepository; - -#[async_trait] -impl FollowMigration for PostgresFederationRepository { - async fn migrate_follower_actor( - &self, - old_actor_url: &str, - new_actor_url: &str, - ) -> Result> { - let candidates: Vec = sqlx::query_scalar( - "SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)", - ).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?; - if candidates.is_empty() { - return Ok(vec![]); - } - sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)") - .bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?; - candidates - .into_iter() - .map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e))) - .collect() - } -} diff --git a/crates/adapters/postgres-federation/src/follow/mod.rs b/crates/adapters/postgres-federation/src/follow/mod.rs deleted file mode 100644 index 414bc5cf..00000000 --- a/crates/adapters/postgres-federation/src/follow/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod followers; -mod following; -mod migration; diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs deleted file mode 100644 index e5c6eeb5..00000000 --- a/crates/adapters/postgres-federation/src/lib.rs +++ /dev/null @@ -1,85 +0,0 @@ -mod activity; -mod actor; -mod blocklist; -mod follow; -mod review; - -use k_ap::{FollowerStatus, RemoteActor}; -use sqlx::{PgPool, Row}; - -pub(crate) fn status_to_str(status: &FollowerStatus) -> &'static str { - match status { - FollowerStatus::Pending => "pending", - FollowerStatus::Accepted => "accepted", - FollowerStatus::Rejected => "rejected", - } -} - -pub(crate) fn str_to_status(s: &str) -> FollowerStatus { - match s { - "accepted" => FollowerStatus::Accepted, - "rejected" => FollowerStatus::Rejected, - _ => FollowerStatus::Pending, - } -} - -pub(crate) fn pg_remote_actor(row: &sqlx::postgres::PgRow, url_col: &str) -> RemoteActor { - RemoteActor { - url: row.get(url_col), - handle: row.try_get("handle").unwrap_or_default(), - inbox_url: row.try_get("inbox_url").unwrap_or_default(), - shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(), - display_name: row.try_get("display_name").ok().flatten(), - avatar_url: row.try_get("avatar_url").ok().flatten(), - outbox_url: row.try_get("outbox_url").ok().flatten(), - bio: row.try_get("bio").ok().flatten(), - banner_url: row.try_get("banner_url").ok().flatten(), - followers_url: row.try_get("followers_url").ok().flatten(), - following_url: row.try_get("following_url").ok().flatten(), - also_known_as: row - .try_get::, _>("also_known_as") - .ok() - .flatten() - .map(|s| { - serde_json::from_str::>(&s).unwrap_or_else(|e| { - tracing::warn!(raw = %s, error = %e, "failed to parse also_known_as JSON"); - vec![s] - }) - }) - .unwrap_or_default(), - fetched_at: row.try_get("fetched_at").ok(), - } -} - -pub(crate) const PG_ACTOR_COLS: &str = "a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url, a.outbox_url, a.bio, a.banner_url, a.followers_url, a.following_url, a.also_known_as, a.fetched_at"; - -pub struct PostgresFederationRepository { - pub(crate) pool: PgPool, -} - -impl PostgresFederationRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn wire( - pool: PgPool, - instance: domain::value_objects::InstanceIdentity, -) -> activitypub::FederationRepos { - let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool.clone())); - let social = std::sync::Arc::new(postgres_social::PostgresSocialRepository::new( - pool, instance, - )); - activitypub::FederationRepos { - activity: std::sync::Arc::clone(&fed) as _, - follow: std::sync::Arc::clone(&fed) as _, - actor: std::sync::Arc::clone(&fed) as _, - blocklist: std::sync::Arc::clone(&fed) as _, - review_store: fed as _, - admin_query: std::sync::Arc::clone(&social) as _, - remote_watchlist: std::sync::Arc::clone(&social) as _, - follow_command: std::sync::Arc::clone(&social) as _, - follow_query: social as _, - } -} diff --git a/crates/adapters/postgres-federation/src/review.rs b/crates/adapters/postgres-federation/src/review.rs deleted file mode 100644 index 9772be39..00000000 --- a/crates/adapters/postgres-federation/src/review.rs +++ /dev/null @@ -1,112 +0,0 @@ -use activitypub::{RemoteReviewRepository, RemoteReviewUpdate}; -use anyhow::{Result, anyhow}; -use async_trait::async_trait; -use domain::models::{Review, ReviewSource}; - -use super::PostgresFederationRepository; -use adapter_common::datetime_to_str; - -#[async_trait] -impl RemoteReviewRepository for PostgresFederationRepository { - async fn save_remote_review( - &self, - review: &Review, - ap_id: &str, - movie_title: &str, - release_year: u16, - external_metadata_id: Option<&str>, - poster_url: Option<&str>, - ) -> Result<()> { - let actor_url = match review.source() { - ReviewSource::Remote { actor_url } => actor_url.clone(), - ReviewSource::Local => { - return Err(anyhow!("save_remote_review called with a local review")); - } - }; - let movie_id = review.movie_id().value().to_string(); - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, NULL, $5) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), - poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", - ) - .bind(&movie_id) - .bind(external_metadata_id) - .bind(movie_title) - .bind(release_year.max(1888) as i64) - .bind(poster_url) - .execute(&self.pool) - .await?; - - let id = review.id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, ap_id, spoilers) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10) ON CONFLICT DO NOTHING", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&actor_url) - .bind(ap_id) - .bind(review.spoilers()) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn delete_remote_review(&self, ap_id: &str, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE ap_id = $1 AND remote_actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } - - async fn update_remote_review(&self, u: RemoteReviewUpdate<'_>) -> Result<()> { - let watched_at_str = datetime_to_str(&u.watched_at); - sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 - WHERE ap_id = $6 AND remote_actor_url = $7", - ) - .bind(u.rating as i64) - .bind(u.comment) - .bind(&watched_at_str) - .bind(u.watch_medium) - .bind(u.spoilers) - .bind(u.ap_id) - .bind(u.actor_url) - .execute(&self.pool) - .await?; - if let Some(url) = u.poster_url { - sqlx::query( - "UPDATE movies SET poster_path = $1 - WHERE id = (SELECT movie_id FROM reviews WHERE ap_id = $2 AND remote_actor_url = $3)", - ) - .bind(url) - .bind(u.ap_id) - .bind(u.actor_url) - .execute(&self.pool) - .await?; - } - Ok(()) - } - - async fn delete_by_actor(&self, actor_url: &str) -> Result<()> { - sqlx::query("DELETE FROM reviews WHERE remote_actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await?; - Ok(()) - } -} diff --git a/crates/adapters/postgres-search/Cargo.toml b/crates/adapters/postgres-search/Cargo.toml deleted file mode 100644 index d3c7ca60..00000000 --- a/crates/adapters/postgres-search/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "postgres-search" -version = "0.1.0" -edition = "2021" - -[dependencies] -domain = { workspace = true } -adapter-common = { workspace = true } -async-trait = { workspace = true } -sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros"] } -uuid = { workspace = true } diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs deleted file mode 100644 index 06c00433..00000000 --- a/crates/adapters/postgres-search/src/lib.rs +++ /dev/null @@ -1,353 +0,0 @@ -use std::sync::Arc; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::PersonId, - models::{ - collections::Paginated, EntityType, IndexableDocument, MovieSearchHit, PersonSearchHit, - SearchQuery, SearchResults, - }, - ports::{SearchCommand, SearchPort}, - value_objects::MovieId, -}; -use sqlx::PgPool; - -pub struct PostgresSearchAdapter { - pool: PgPool, -} - -impl PostgresSearchAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_search_adapter(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresSearchAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl SearchCommand for PostgresSearchAdapter { - async fn index(&self, doc: IndexableDocument) -> Result<(), DomainError> { - match doc { - IndexableDocument::Movie { id, movie, profile } => { - let movie_id = id.value().to_string(); - let title = movie.title().value().to_string(); - let director = movie.director().unwrap_or("").to_string(); - let (overview, genres, keywords, cast_names, crew_names) = match profile.as_deref() - { - Some(p) => ( - p.overview.clone().unwrap_or_default(), - p.genres - .iter() - .map(|g| g.name.as_str()) - .collect::>() - .join(" "), - p.keywords - .iter() - .map(|k| k.name.as_str()) - .collect::>() - .join(" "), - p.cast - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - p.crew - .iter() - .map(|c| c.name.as_str()) - .collect::>() - .join(" "), - ), - None => ( - String::new(), - String::new(), - String::new(), - String::new(), - String::new(), - ), - }; - - let fts_input = format!( - "{} {} {} {} {} {} {}", - title, director, overview, genres, keywords, cast_names, crew_names - ); - - sqlx::query( - "INSERT INTO movies_search (movie_id, fts) - VALUES ($1, to_tsvector('english', $2)) - ON CONFLICT (movie_id) DO UPDATE SET fts = EXCLUDED.fts", - ) - .bind(&movie_id) - .bind(&fts_input) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - IndexableDocument::Person { id, person } => { - let person_id = id.value().to_string(); - let fts_input = format!( - "{} {}", - person.name(), - person.known_for_department().unwrap_or("") - ); - - sqlx::query( - "INSERT INTO people_search (person_id, fts) - VALUES ($1, to_tsvector('english', $2)) - ON CONFLICT (person_id) DO UPDATE SET fts = EXCLUDED.fts", - ) - .bind(&person_id) - .bind(&fts_input) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - } - } - - async fn remove(&self, entity_type: EntityType, id: &str) -> Result<(), DomainError> { - match entity_type { - EntityType::Movie => { - sqlx::query("DELETE FROM movies_search WHERE movie_id = $1") - .bind(id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - EntityType::Person => { - sqlx::query("DELETE FROM people_search WHERE person_id = $1") - .bind(id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - } - Ok(()) - } -} - -#[async_trait] -impl SearchPort for PostgresSearchAdapter { - async fn search(&self, query: &SearchQuery) -> Result { - let movies = self.search_movies(query).await?; - let people = self.search_people(query).await?; - Ok(SearchResults { movies, people }) - } -} - -impl PostgresSearchAdapter { - async fn search_movies( - &self, - query: &SearchQuery, - ) -> Result, DomainError> { - let limit = query.page.limit as i64; - let offset = query.page.offset as i64; - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - title: String, - release_year: Option, - director: Option, - poster_path: Option, - genres: Option, - } - - let total: u64 = if let Some(text) = &query.text { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(DISTINCT m.id) - FROM movies_search ms - JOIN movies m ON m.id = ms.movie_id - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ms.fts @@ plainto_tsquery('english', $1) - AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2)) - AND ($3::INT IS NULL OR m.release_year = $3)", - ) - .bind(text) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - } else { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(DISTINCT m.id) FROM movies m - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1)) - AND ($2::INT IS NULL OR m.release_year = $2)", - ) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - }; - - let rows: Vec = if let Some(text) = &query.text { - sqlx::query_as::<_, Row>( - "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, - STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres - FROM movies_search ms - JOIN movies m ON m.id = ms.movie_id - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ms.fts @@ plainto_tsquery('english', $1) - AND ($2::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $2)) - AND ($3::INT IS NULL OR m.release_year = $3) - GROUP BY m.id, m.title, m.release_year, m.director, m.poster_path, ms.fts - ORDER BY ts_rank(ms.fts, plainto_tsquery('english', $1)) DESC - LIMIT $4 OFFSET $5", - ) - .bind(text) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - } else { - sqlx::query_as::<_, Row>( - "SELECT m.id, m.title, m.release_year, m.director, m.poster_path, - STRING_AGG(DISTINCT mg.name, ',' ORDER BY mg.name) AS genres - FROM movies m - LEFT JOIN movie_genres mg ON mg.movie_id = m.id - WHERE ($1::TEXT IS NULL OR EXISTS (SELECT 1 FROM movie_genres WHERE movie_id = m.id AND name = $1)) - AND ($2::INT IS NULL OR m.release_year = $2) - GROUP BY m.id ORDER BY m.title LIMIT $3 OFFSET $4", - ) - .bind(&query.filters.genre) - .bind(query.filters.year.map(|y| y as i32)) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - }; - - let items = rows - .into_iter() - .map(|r| MovieSearchHit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - director: r.director, - poster_path: r.poster_path, - genres: r - .genres - .unwrap_or_default() - .split(',') - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(), - }) - .collect::>(); - - Ok(Paginated { - items, - total_count: total, - limit: query.page.limit, - offset: query.page.offset, - }) - } - - async fn search_people( - &self, - query: &SearchQuery, - ) -> Result, DomainError> { - let Some(text) = &query.text else { - return Ok(Paginated { - items: vec![], - total_count: 0, - limit: query.page.limit, - offset: query.page.offset, - }); - }; - - let limit = query.page.limit as i64; - let offset = query.page.offset as i64; - - let total: u64 = { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM people_search WHERE fts @@ plainto_tsquery('english', $1)", - ) - .bind(text) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count as u64 - }; - - #[derive(sqlx::FromRow)] - struct Row { - person_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - tmdb_person_id: Option, - } - - let rows = sqlx::query_as::<_, Row>( - "SELECT ps.person_id, p.name, p.known_for_department, p.profile_path, p.tmdb_person_id - FROM people_search ps - JOIN persons p ON p.id = ps.person_id - WHERE ps.fts @@ plainto_tsquery('english', $1) - ORDER BY ts_rank(ps.fts, plainto_tsquery('english', $1)) DESC - LIMIT $2 OFFSET $3", - ) - .bind(text) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut items = Vec::with_capacity(rows.len()); - for row in rows { - let known_for_titles = if let Some(tid) = row.tmdb_person_id { - sqlx::query_scalar::<_, String>( - "SELECT m.title FROM movie_cast mc - JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 - ORDER BY mc.billing_order - LIMIT 3", - ) - .bind(tid) - .fetch_all(&self.pool) - .await - .unwrap_or_default() - } else { - vec![] - }; - - items.push(PersonSearchHit { - person_id: PersonId::from_uuid( - uuid::Uuid::parse_str(&row.person_id).unwrap_or_default(), - ), - name: row.name, - known_for_department: row.known_for_department, - profile_path: row.profile_path, - known_for_titles, - }); - } - - Ok(Paginated { - items, - total_count: total, - limit: query.page.limit, - offset: query.page.offset, - }) - } -} diff --git a/crates/adapters/postgres-social/Cargo.toml b/crates/adapters/postgres-social/Cargo.toml deleted file mode 100644 index 9354c526..00000000 --- a/crates/adapters/postgres-social/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "postgres-social" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -adapter-common = { workspace = true } -domain = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -async-trait = { workspace = true } diff --git a/crates/adapters/postgres-social/src/ap_content.rs b/crates/adapters/postgres-social/src/ap_content.rs deleted file mode 100644 index 03444553..00000000 --- a/crates/adapters/postgres-social/src/ap_content.rs +++ /dev/null @@ -1,301 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - DiaryEntry, Movie, PersistedReview, Review, ReviewSource, WatchlistEntry, - WatchlistWithMovie, - }, - ports::LocalApContentQuery, - value_objects::{ - Comment, ExternalMetadataId, MovieId, MovieTitle, PosterPath, Rating, ReleaseYear, - ReviewId, UserId, WatchlistEntryId, - }, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresApContentQuery { - pool: PgPool, -} - -impl PostgresApContentQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -// ── Local row types ────────────────────────────────────────────────────────── - -use adapter_common::{parse_datetime, parse_uuid}; - -#[derive(sqlx::FromRow)] -struct MovieRow { - id: String, - external_metadata_id: Option, - title: String, - release_year: i64, - director: Option, - poster_path: Option, -} - -impl MovieRow { - fn into_domain(self) -> Result { - let id = MovieId::from_uuid(parse_uuid(&self.id)?); - let external_metadata_id = self - .external_metadata_id - .map(ExternalMetadataId::new) - .transpose()?; - let title = MovieTitle::new(self.title)?; - let release_year = ReleaseYear::new(self.release_year as u16)?; - let poster_path = self.poster_path.map(PosterPath::new).transpose()?; - Ok(Movie::from_persistence( - id, - external_metadata_id, - title, - release_year, - self.director, - poster_path, - )) - } -} - -#[derive(sqlx::FromRow)] -struct ReviewRow { - id: String, - movie_id: String, - user_id: String, - rating: i64, - comment: Option, - watched_at: String, - created_at: String, - remote_actor_url: Option, - watch_medium: Option, - spoilers: bool, -} - -impl ReviewRow { - fn into_domain(self) -> Result { - let id = ReviewId::from_uuid(parse_uuid(&self.id)?); - let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?); - let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?); - let rating = Rating::new(self.rating as u8)?; - let comment = self.comment.map(Comment::new).transpose()?; - let watched_at = parse_datetime(&self.watched_at)?; - let created_at = parse_datetime(&self.created_at)?; - let source = match self.remote_actor_url { - None => ReviewSource::Local, - Some(url) => ReviewSource::Remote { actor_url: url }, - }; - let watch_medium = self.watch_medium.map(|s| s.parse()).transpose()?; - Ok(Review::from_persistence(PersistedReview { - id, - movie_id, - user_id, - rating, - comment, - watched_at, - created_at, - source, - watch_medium, - spoilers: self.spoilers, - })) - } -} - -#[derive(sqlx::FromRow)] -struct DiaryRow { - id: String, - external_metadata_id: Option, - title: String, - release_year: i64, - director: Option, - poster_path: Option, - review_id: String, - movie_id: String, - user_id: String, - rating: i64, - comment: Option, - watched_at: String, - created_at: String, - remote_actor_url: Option, - watch_medium: Option, - spoilers: bool, -} - -impl DiaryRow { - fn into_domain(self) -> Result { - let movie = MovieRow { - id: self.id, - external_metadata_id: self.external_metadata_id, - title: self.title, - release_year: self.release_year, - director: self.director, - poster_path: self.poster_path, - } - .into_domain()?; - let review = ReviewRow { - id: self.review_id, - movie_id: self.movie_id, - user_id: self.user_id, - rating: self.rating, - comment: self.comment, - watched_at: self.watched_at, - created_at: self.created_at, - remote_actor_url: self.remote_actor_url, - watch_medium: self.watch_medium, - spoilers: self.spoilers, - } - .into_domain()?; - Ok(DiaryEntry::new(movie, review)) - } -} - -// ── LocalApContentQuery impl ───────────────────────────────────────────────── - -#[async_trait] -impl LocalApContentQuery for PostgresApContentQuery { - async fn get_local_watchlist_for_user( - &self, - user_id: &UserId, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query( - "SELECT w.id, w.user_id, w.movie_id, - to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, - m.director, m.poster_path - FROM watchlist_entries w - JOIN movies m ON m.id = w.movie_id - WHERE w.user_id = $1 - ORDER BY w.added_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter() - .map(|row| { - let entry = WatchlistEntry { - id: WatchlistEntryId::from_uuid(parse_uuid( - &row.try_get::("id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - user_id: UserId::from_uuid(parse_uuid( - &row.try_get::("user_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - movie_id: MovieId::from_uuid(parse_uuid( - &row.try_get::("movie_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - added_at: parse_datetime( - &row.try_get::("added_at") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?, - }; - let movie = MovieRow { - id: row - .try_get("m_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - external_metadata_id: row - .try_get("external_metadata_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - title: row - .try_get("title") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - release_year: row - .try_get("release_year") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - director: row - .try_get("director") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - poster_path: row - .try_get("poster_path") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - } - .into_domain()?; - Ok(WatchlistWithMovie { entry, movie }) - }) - .collect() - } - - async fn get_local_reviews_for_movie( - &self, - movie_id: &MovieId, - ) -> Result, DomainError> { - let mid = movie_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = $1 AND r.remote_actor_url IS NULL - ORDER BY r.created_at DESC", - ) - .bind(&mid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - async fn get_local_reviews_page( - &self, - user_id: &UserId, - before: Option, - limit: usize, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit_i64 = limit as i64; - - let rows = if let Some(before_ts) = before { - let ts = before_ts.format("%Y-%m-%d %H:%M:%S").to_string(); - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL AND r.watched_at < $2::timestamptz - ORDER BY r.watched_at DESC - LIMIT $3", - ) - .bind(&uid) - .bind(&ts) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - } else { - sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL - ORDER BY r.watched_at DESC - LIMIT $2", - ) - .bind(&uid) - .bind(limit_i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - }; - rows.into_iter().map(DiaryRow::into_domain).collect() - } -} diff --git a/crates/adapters/postgres-social/src/federated_profile.rs b/crates/adapters/postgres-social/src/federated_profile.rs deleted file mode 100644 index c32f3602..00000000 --- a/crates/adapters/postgres-social/src/federated_profile.rs +++ /dev/null @@ -1,58 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery}; -use sqlx::Row; - -use super::PostgresSocialRepository; - -#[async_trait] -impl FederatedProfileQuery for PostgresSocialRepository { - async fn get_federated_profile( - &self, - synthetic_user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = synthetic_user_id.to_string(); - - let actor_url: Option = sqlx::query_scalar( - "SELECT remote_actor_url FROM reviews - WHERE user_id = $1 AND remote_actor_url IS NOT NULL - LIMIT 1", - ) - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - let actor_url = match actor_url { - Some(url) => url, - None => return Ok(None), - }; - - let row = sqlx::query( - "SELECT handle, display_name, bio, avatar_url, banner_url - FROM ap_remote_actors WHERE url = $1", - ) - .bind(&actor_url) - .fetch_optional(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - match row { - Some(r) => Ok(Some(FederatedProfile { - actor_url, - handle: r.get("handle"), - display_name: r.try_get("display_name").ok().flatten(), - bio: r.try_get("bio").ok().flatten(), - avatar_url: r.try_get("avatar_url").ok().flatten(), - banner_url: r.try_get("banner_url").ok().flatten(), - })), - None => Ok(Some(FederatedProfile { - handle: actor_url.clone(), - actor_url, - display_name: None, - bio: None, - avatar_url: None, - banner_url: None, - })), - } - } -} diff --git a/crates/adapters/postgres-social/src/follow_repository.rs b/crates/adapters/postgres-social/src/follow_repository.rs deleted file mode 100644 index 2c241157..00000000 --- a/crates/adapters/postgres-social/src/follow_repository.rs +++ /dev/null @@ -1,354 +0,0 @@ -use async_trait::async_trait; -use chrono::Utc; -use domain::{ - errors::DomainError, - value_objects::{FollowRelation, FollowStatus, InstanceIdentity, SocialActor, SocialIdentity}, -}; -use sqlx::Row; - -use crate::PostgresSocialRepository; -use adapter_common::datetime_to_str; - -fn follow_status_to_str(status: &FollowStatus) -> &'static str { - match status { - FollowStatus::Pending => "pending", - FollowStatus::Accepted => "accepted", - FollowStatus::Rejected => "rejected", - } -} - -fn infra_err(e: impl std::fmt::Display) -> DomainError { - DomainError::InfrastructureError(e.to_string()) -} - -fn follow_status_from_str(status: &str) -> Option { - match status { - "pending" => Some(FollowStatus::Pending), - "accepted" => Some(FollowStatus::Accepted), - "rejected" => Some(FollowStatus::Rejected), - _ => None, - } -} - -#[async_trait] -impl domain::ports::FollowCommand for PostgresSocialRepository { - async fn add_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_following (local_user_id, remote_actor_url, follow_activity_id, created_at, status) - VALUES ($1, $2, '', $3::timestamptz, $4) - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status", - ) - .bind(&uid) - .bind(target_actor_url) - .bind(&now) - .bind(status_str) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follow_status( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ) - .bind(status_str) - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follow( - &self, - follower_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = follower_id.to_string(); - sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(target_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn add_follower( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - let now = datetime_to_str(&Utc::now().naive_utc()); - sqlx::query( - "INSERT INTO ap_followers (local_user_id, remote_actor_url, status, created_at, follow_activity_id) - VALUES ($1, $2, $3, $4::timestamptz, '') - ON CONFLICT(local_user_id, remote_actor_url) DO UPDATE SET status = EXCLUDED.status", - ) - .bind(&uid) - .bind(follower_actor_url) - .bind(status_str) - .bind(&now) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn update_follower_status( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - status: FollowStatus, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - let status_str = follow_status_to_str(&status); - sqlx::query( - "UPDATE ap_followers SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3", - ) - .bind(status_str) - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } - - async fn remove_follower_record( - &self, - local_user_id: uuid::Uuid, - follower_actor_url: &str, - ) -> Result<(), DomainError> { - let uid = local_user_id.to_string(); - sqlx::query("DELETE FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2") - .bind(&uid) - .bind(follower_actor_url) - .execute(&self.pool) - .await - .map_err(infra_err)?; - Ok(()) - } -} - -fn social_actor_from_row(row: &sqlx::postgres::PgRow, instance: &InstanceIdentity) -> SocialActor { - let actor_url: String = row.get("remote_actor_url"); - let identity = instance.identify(&actor_url); - - let (handle, display_name, avatar_url) = match &identity { - SocialIdentity::Local(_) => { - let username: Option = row.try_get("local_username").ok().flatten(); - let display: Option = row.try_get("local_display").ok().flatten(); - let avatar: Option = row - .try_get::, _>("local_avatar_path") - .ok() - .flatten() - .map(|p| instance.image_url_for(&p)); - let handle = username - .as_deref() - .map(|u| instance.handle_for(u)) - .unwrap_or_else(|| actor_url.clone()); - (handle, display, avatar) - } - SocialIdentity::Remote { .. } => { - let handle: String = row - .try_get::, _>("remote_handle") - .ok() - .flatten() - .unwrap_or_else(|| actor_url.clone()); - let display: Option = row.try_get("remote_display").ok().flatten(); - let avatar: Option = row.try_get("remote_avatar").ok().flatten(); - (handle, display, avatar) - } - }; - - SocialActor { - identity, - handle, - display_name, - avatar_url, - } -} - -#[async_trait] -impl domain::ports::FollowQuery for PostgresSocialRepository { - async fn get_following(&self, user_id: uuid::Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_following f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'accepted'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_followers(&self, user_id: uuid::Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_followers f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'accepted'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_pending_followers( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_followers f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'pending'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn get_pending_following( - &self, - user_id: uuid::Uuid, - ) -> Result, DomainError> { - let uid = user_id.to_string(); - let rows = sqlx::query( - "SELECT f.remote_actor_url, - u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path, - a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar - FROM ap_following f - LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id - LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url - WHERE f.local_user_id = $2 AND f.status = 'pending'", - ) - .bind(self.instance.base_url()) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(infra_err)?; - Ok(rows - .iter() - .map(|r| social_actor_from_row(r, &self.instance)) - .collect()) - } - - async fn count_following(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn count_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result { - let uid = user_id.to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'pending'", - ) - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - Ok(count as usize) - } - - async fn get_relation( - &self, - viewer_id: uuid::Uuid, - target_actor_url: &str, - ) -> Result { - let uid = viewer_id.to_string(); - let row = sqlx::query( - "SELECT (SELECT status FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2) AS following, - (SELECT status FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2) AS followed_by", - ) - .bind(&uid) - .bind(target_actor_url) - .fetch_one(&self.pool) - .await - .map_err(infra_err)?; - - Ok(FollowRelation { - following: row - .try_get::, _>("following") - .map_err(infra_err)? - .as_deref() - .and_then(follow_status_from_str), - followed_by: row - .try_get::, _>("followed_by") - .map_err(infra_err)? - .as_deref() - .and_then(follow_status_from_str), - }) - } -} diff --git a/crates/adapters/postgres-social/src/lib.rs b/crates/adapters/postgres-social/src/lib.rs deleted file mode 100644 index 02753589..00000000 --- a/crates/adapters/postgres-social/src/lib.rs +++ /dev/null @@ -1,30 +0,0 @@ -mod federated_profile; -mod follow_repository; -mod social; -mod watchlist; - -pub mod ap_content; -pub mod remote_goals; - -pub use ap_content::PostgresApContentQuery; -pub use remote_goals::PostgresRemoteGoalRepository; - -use sqlx::PgPool; - -pub struct PostgresSocialRepository { - pub(crate) pool: PgPool, - pub(crate) instance: domain::value_objects::InstanceIdentity, -} - -impl PostgresSocialRepository { - pub fn new(pool: PgPool, instance: domain::value_objects::InstanceIdentity) -> Self { - Self { pool, instance } - } -} - -pub fn create_federated_profile_query( - pool: PgPool, - instance: domain::value_objects::InstanceIdentity, -) -> std::sync::Arc { - std::sync::Arc::new(PostgresSocialRepository::new(pool, instance)) -} diff --git a/crates/adapters/postgres-social/src/remote_goals.rs b/crates/adapters/postgres-social/src/remote_goals.rs deleted file mode 100644 index 21a6d08d..00000000 --- a/crates/adapters/postgres-social/src/remote_goals.rs +++ /dev/null @@ -1,114 +0,0 @@ -use async_trait::async_trait; -use chrono::TimeZone; -use domain::{errors::DomainError, models::RemoteGoalEntry, ports::RemoteGoalRepository}; -use sqlx::{PgPool, Row}; - -pub struct PostgresRemoteGoalRepository { - pool: PgPool, -} - -impl PostgresRemoteGoalRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl RemoteGoalRepository for PostgresRemoteGoalRepository { - async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError> { - let received = entry.received_at.format("%Y-%m-%d %H:%M:%S").to_string(); - - sqlx::query( - "INSERT INTO remote_goals \ - (ap_id, actor_url, year, target_count, current_count, received_at) \ - VALUES ($1, $2, $3, $4, $5, $6::timestamptz) \ - ON CONFLICT (ap_id) DO UPDATE SET \ - target_count = $4, current_count = $5", - ) - .bind(&entry.ap_id) - .bind(&entry.actor_url) - .bind(entry.year as i64) - .bind(entry.target_count as i64) - .bind(entry.current_count as i64) - .bind(&received) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_by_ap_id( - &self, - ap_id: &str, - target: u32, - current: u32, - ) -> Result<(), DomainError> { - sqlx::query( - "UPDATE remote_goals SET target_count = $1, current_count = $2 WHERE ap_id = $3", - ) - .bind(target as i64) - .bind(current as i64) - .bind(ap_id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM remote_goals WHERE ap_id = $1 AND actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM remote_goals WHERE actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_actor_url(&self, actor_url: &str) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT ap_id, actor_url, year, target_count, current_count, \ - to_char(received_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS received_at \ - FROM remote_goals WHERE actor_url = $1 ORDER BY year DESC", - ) - .bind(actor_url) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter() - .map(|r| { - let year: i64 = r.try_get("year").unwrap_or(0); - let target: i64 = r.try_get("target_count").unwrap_or(0); - let current: i64 = r.try_get("current_count").unwrap_or(0); - let received_str: String = r.try_get("received_at").unwrap_or_default(); - let received_at = - chrono::NaiveDateTime::parse_from_str(&received_str, "%Y-%m-%d %H:%M:%S") - .map(|ndt| chrono::Utc.from_utc_datetime(&ndt)) - .unwrap_or_else(|_| chrono::Utc::now()); - - Ok(RemoteGoalEntry { - ap_id: r.try_get("ap_id").unwrap_or_default(), - actor_url: r.try_get("actor_url").unwrap_or_default(), - year: year as u16, - target_count: target as u32, - current_count: current as u32, - received_at, - }) - }) - .collect() - } -} diff --git a/crates/adapters/postgres-social/src/social.rs b/crates/adapters/postgres-social/src/social.rs deleted file mode 100644 index 8f6dac42..00000000 --- a/crates/adapters/postgres-social/src/social.rs +++ /dev/null @@ -1,21 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::RemoteActorInfo, ports::FederationAdminQuery}; - -use super::PostgresSocialRepository; - -#[async_trait] -impl FederationAdminQuery for PostgresSocialRepository { - async fn list_all_followed_remote_actors(&self) -> Result, DomainError> { - let rows = sqlx::query_as::<_, (String, String, Option)>( - "SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'", - ).fetch_all(&self.pool).await.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map(|(url, handle, display_name)| RemoteActorInfo { - url, - handle, - display_name, - }) - .collect()) - } -} diff --git a/crates/adapters/postgres-social/src/watchlist.rs b/crates/adapters/postgres-social/src/watchlist.rs deleted file mode 100644 index 903dde11..00000000 --- a/crates/adapters/postgres-social/src/watchlist.rs +++ /dev/null @@ -1,91 +0,0 @@ -use async_trait::async_trait; -use domain::{errors::DomainError, models::RemoteWatchlistEntry, ports::RemoteWatchlistRepository}; -use sqlx::Row; - -use super::PostgresSocialRepository; - -#[async_trait] -impl RemoteWatchlistRepository for PostgresSocialRepository { - async fn save(&self, entry: RemoteWatchlistEntry) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO ap_remote_watchlist_entries \ - (ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7) \ - ON CONFLICT(ap_id) DO UPDATE SET \ - movie_title=excluded.movie_title, release_year=excluded.release_year, \ - external_metadata_id=excluded.external_metadata_id, poster_url=excluded.poster_url", - ) - .bind(&entry.ap_id).bind(&entry.actor_url).bind(&entry.movie_title) - .bind(entry.release_year as i32).bind(&entry.external_metadata_id).bind(&entry.poster_url) - .bind(entry.added_at) - .execute(&self.pool).await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn remove_by_ap_id(&self, ap_id: &str, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE ap_id = $1 AND actor_url = $2") - .bind(ap_id) - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_actor_url( - &self, - actor_url: &str, - ) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT ap_id, actor_url, movie_title, release_year, external_metadata_id, poster_url, added_at \ - FROM ap_remote_watchlist_entries WHERE actor_url = $1 ORDER BY added_at DESC", - ).bind(actor_url).fetch_all(&self.pool).await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - rows.into_iter() - .map(|row| { - Ok(RemoteWatchlistEntry { - ap_id: row.try_get("ap_id").unwrap_or_default(), - actor_url: row.try_get("actor_url").unwrap_or_default(), - movie_title: row.try_get("movie_title").unwrap_or_default(), - release_year: row.try_get::("release_year").unwrap_or(0) as u16, - external_metadata_id: row.try_get("external_metadata_id").ok().flatten(), - poster_url: row.try_get("poster_url").ok().flatten(), - added_at: row - .try_get::, _>("added_at") - .unwrap_or_else(|_| chrono::Utc::now()), - }) - }) - .collect() - } - - async fn remove_all_by_actor(&self, actor_url: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM ap_remote_watchlist_entries WHERE actor_url = $1") - .bind(actor_url) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn get_by_derived_uuid( - &self, - uuid: uuid::Uuid, - ) -> Result, DomainError> { - let actors: Vec = - sqlx::query("SELECT DISTINCT actor_url FROM ap_remote_watchlist_entries") - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))? - .into_iter() - .filter_map(|row| row.try_get::("actor_url").ok()) - .collect(); - let target = actors - .into_iter() - .find(|url| uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, url.as_bytes()) == uuid); - match target { - None => Ok(vec![]), - Some(actor_url) => self.get_by_actor_url(&actor_url).await, - } - } -} diff --git a/crates/adapters/postgres/Cargo.toml b/crates/adapters/postgres/Cargo.toml deleted file mode 100644 index 30627837..00000000 --- a/crates/adapters/postgres/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -[package] -name = "postgres" -version = "0.1.0" -edition.workspace = true - -[dependencies] -sqlx = { version = "0.8.6", features = [ - "runtime-tokio-rustls", - "postgres", - "uuid", - "macros", - "chrono", -] } -adapter-common = { workspace = true } -domain = { workspace = true } -postgres-social = { workspace = true } -anyhow = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } -tracing = { workspace = true } -async-trait = { workspace = true } -tokio = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -futures = { workspace = true } -bytes = { workspace = true } -async-stream = { workspace = true } diff --git a/crates/adapters/postgres/migrations/0001_initial.sql b/crates/adapters/postgres/migrations/0001_initial.sql deleted file mode 100644 index 63a2ed0f..00000000 --- a/crates/adapters/postgres/migrations/0001_initial.sql +++ /dev/null @@ -1,69 +0,0 @@ -CREATE TABLE IF NOT EXISTS movies ( - id TEXT PRIMARY KEY NOT NULL, - external_metadata_id TEXT UNIQUE, - title TEXT NOT NULL, - release_year BIGINT NOT NULL, - director TEXT, - poster_path TEXT -); - -CREATE INDEX IF NOT EXISTS idx_movies_title_year ON movies (title, release_year); - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY NOT NULL, - email TEXT UNIQUE NOT NULL, - username TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - role TEXT NOT NULL DEFAULT 'standard' -); - -CREATE TABLE IF NOT EXISTS reviews ( - id TEXT PRIMARY KEY NOT NULL, - movie_id TEXT NOT NULL REFERENCES movies(id), - user_id TEXT NOT NULL, - rating BIGINT NOT NULL, - comment TEXT, - watched_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL, - remote_actor_url TEXT, - ap_id TEXT -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_ap_id ON reviews (ap_id) WHERE ap_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_reviews_movie_id ON reviews (movie_id); -CREATE INDEX IF NOT EXISTS idx_reviews_watched_at ON reviews (watched_at); - -CREATE TABLE IF NOT EXISTS ap_followers ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - follow_activity_id TEXT, - created_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); - -CREATE TABLE IF NOT EXISTS ap_following ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - follow_activity_id TEXT, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); - -CREATE TABLE IF NOT EXISTS ap_remote_actors ( - url TEXT PRIMARY KEY, - handle TEXT NOT NULL, - inbox_url TEXT NOT NULL, - shared_inbox_url TEXT, - display_name TEXT, - fetched_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS ap_local_actors ( - user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, - public_key TEXT NOT NULL, - private_key TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); diff --git a/crates/adapters/postgres/migrations/0002_import.sql b/crates/adapters/postgres/migrations/0002_import.sql deleted file mode 100644 index 97c0edd9..00000000 --- a/crates/adapters/postgres/migrations/0002_import.sql +++ /dev/null @@ -1,21 +0,0 @@ -CREATE TABLE IF NOT EXISTS import_sessions ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - parsed_data TEXT NOT NULL, - field_mappings TEXT, - row_results TEXT, - created_at TIMESTAMPTZ NOT NULL, - expires_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS import_profiles ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - field_mappings TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_import_sessions_user_id ON import_sessions (user_id); -CREATE INDEX IF NOT EXISTS idx_import_sessions_expires_at ON import_sessions (expires_at); -CREATE INDEX IF NOT EXISTS idx_import_profiles_user_id ON import_profiles (user_id); diff --git a/crates/adapters/postgres/migrations/0009_user_profile.sql b/crates/adapters/postgres/migrations/0009_user_profile.sql deleted file mode 100644 index 2d7dcfb9..00000000 --- a/crates/adapters/postgres/migrations/0009_user_profile.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE users ADD COLUMN bio TEXT; -ALTER TABLE users ADD COLUMN avatar_path TEXT; diff --git a/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql b/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql deleted file mode 100644 index 36624432..00000000 --- a/crates/adapters/postgres/migrations/0010_ap_remote_actor_avatar.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT; diff --git a/crates/adapters/postgres/migrations/0011_ap_announces.sql b/crates/adapters/postgres/migrations/0011_ap_announces.sql deleted file mode 100644 index 2ab1467f..00000000 --- a/crates/adapters/postgres/migrations/0011_ap_announces.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE ap_announces ( - id TEXT PRIMARY KEY, - object_url TEXT NOT NULL, - actor_url TEXT NOT NULL, - announced_at TEXT NOT NULL -); - -CREATE INDEX idx_ap_announces_object ON ap_announces (object_url); diff --git a/crates/adapters/postgres/migrations/0012_blocked_domains.sql b/crates/adapters/postgres/migrations/0012_blocked_domains.sql deleted file mode 100644 index 4680c892..00000000 --- a/crates/adapters/postgres/migrations/0012_blocked_domains.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE blocked_domains ( - domain TEXT PRIMARY KEY, - reason TEXT, - blocked_at TEXT NOT NULL -); diff --git a/crates/adapters/postgres/migrations/0013_blocked_actors.sql b/crates/adapters/postgres/migrations/0013_blocked_actors.sql deleted file mode 100644 index 0b1245ed..00000000 --- a/crates/adapters/postgres/migrations/0013_blocked_actors.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE blocked_actors ( - local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - remote_actor_url TEXT NOT NULL, - blocked_at TEXT NOT NULL, - PRIMARY KEY (local_user_id, remote_actor_url) -); diff --git a/crates/adapters/postgres/migrations/0014_movie_profiles.sql b/crates/adapters/postgres/migrations/0014_movie_profiles.sql deleted file mode 100644 index c3bdfc83..00000000 --- a/crates/adapters/postgres/migrations/0014_movie_profiles.sql +++ /dev/null @@ -1,54 +0,0 @@ -CREATE TABLE IF NOT EXISTS movie_profiles ( - movie_id TEXT PRIMARY KEY NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id BIGINT NOT NULL, - imdb_id TEXT, - overview TEXT, - tagline TEXT, - runtime_minutes INTEGER, - budget_usd BIGINT, - revenue_usd BIGINT, - vote_average DOUBLE PRECISION, - vote_count INTEGER, - original_language TEXT, - collection_name TEXT, - enriched_at TIMESTAMPTZ NOT NULL -); - -CREATE TABLE IF NOT EXISTS movie_genres ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id INTEGER NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (movie_id, tmdb_id) -); - -CREATE TABLE IF NOT EXISTS movie_keywords ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_id INTEGER NOT NULL, - name TEXT NOT NULL, - PRIMARY KEY (movie_id, tmdb_id) -); - -CREATE TABLE IF NOT EXISTS movie_cast ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_person_id BIGINT NOT NULL, - name TEXT NOT NULL, - character TEXT NOT NULL, - billing_order INTEGER NOT NULL, - profile_path TEXT, - PRIMARY KEY (movie_id, tmdb_person_id) -); - -CREATE TABLE IF NOT EXISTS movie_crew ( - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - tmdb_person_id BIGINT NOT NULL, - name TEXT NOT NULL, - job TEXT NOT NULL, - department TEXT NOT NULL, - profile_path TEXT, - PRIMARY KEY (movie_id, tmdb_person_id, job) -); - -CREATE INDEX IF NOT EXISTS idx_movie_cast_person ON movie_cast (tmdb_person_id); -CREATE INDEX IF NOT EXISTS idx_movie_crew_person ON movie_crew (tmdb_person_id); -CREATE INDEX IF NOT EXISTS idx_movie_genres_name ON movie_genres (name); -CREATE INDEX IF NOT EXISTS idx_movie_keywords_name ON movie_keywords (name); diff --git a/crates/adapters/postgres/migrations/0015_search.sql b/crates/adapters/postgres/migrations/0015_search.sql deleted file mode 100644 index 0a57b4bc..00000000 --- a/crates/adapters/postgres/migrations/0015_search.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS persons ( - id TEXT PRIMARY KEY, - external_id TEXT NOT NULL UNIQUE, - tmdb_person_id BIGINT UNIQUE, - name TEXT NOT NULL, - known_for_department TEXT, - profile_path TEXT -); - -CREATE INDEX IF NOT EXISTS idx_persons_external ON persons (external_id); -CREATE INDEX IF NOT EXISTS idx_persons_tmdb_id ON persons (tmdb_person_id); - --- tsvector-based search for movies (equivalent of SQLite FTS5) -CREATE TABLE IF NOT EXISTS movies_search ( - movie_id TEXT PRIMARY KEY REFERENCES movies(id) ON DELETE CASCADE, - fts TSVECTOR NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_movies_search_fts ON movies_search USING GIN(fts); - --- tsvector-based search for people -CREATE TABLE IF NOT EXISTS people_search ( - person_id TEXT PRIMARY KEY REFERENCES persons(id) ON DELETE CASCADE, - fts TSVECTOR NOT NULL DEFAULT '' -); -CREATE INDEX IF NOT EXISTS idx_people_search_fts ON people_search USING GIN(fts); diff --git a/crates/adapters/postgres/migrations/0016_watchlist.sql b/crates/adapters/postgres/migrations/0016_watchlist.sql deleted file mode 100644 index dcbcef17..00000000 --- a/crates/adapters/postgres/migrations/0016_watchlist.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS watchlist_entries ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - movie_id TEXT NOT NULL REFERENCES movies(id) ON DELETE CASCADE, - added_at TIMESTAMPTZ NOT NULL, - UNIQUE(user_id, movie_id) -); - -CREATE INDEX IF NOT EXISTS idx_watchlist_user ON watchlist_entries(user_id, added_at DESC); diff --git a/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql b/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql deleted file mode 100644 index cd38bf11..00000000 --- a/crates/adapters/postgres/migrations/0017_ap_remote_watchlist.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS ap_remote_watchlist_entries ( - ap_id TEXT PRIMARY KEY NOT NULL, - actor_url TEXT NOT NULL, - movie_title TEXT NOT NULL, - release_year INTEGER NOT NULL, - external_metadata_id TEXT, - poster_url TEXT, - added_at TIMESTAMPTZ NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_remote_watchlist_actor - ON ap_remote_watchlist_entries(actor_url); diff --git a/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql b/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql deleted file mode 100644 index a767d7d3..00000000 --- a/crates/adapters/postgres/migrations/0018_remote_actor_outbox_url.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE ap_remote_actors ADD COLUMN outbox_url TEXT; diff --git a/crates/adapters/postgres/migrations/0019_profile_extended.sql b/crates/adapters/postgres/migrations/0019_profile_extended.sql deleted file mode 100644 index b2fa2b71..00000000 --- a/crates/adapters/postgres/migrations/0019_profile_extended.sql +++ /dev/null @@ -1,13 +0,0 @@ -ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_path TEXT; -ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT; - -CREATE TABLE IF NOT EXISTS user_profile_fields ( - id TEXT NOT NULL PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - value TEXT NOT NULL, - position INTEGER NOT NULL -); - -CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id - ON user_profile_fields(user_id); diff --git a/crates/adapters/postgres/migrations/0020_kap_v03.sql b/crates/adapters/postgres/migrations/0020_kap_v03.sql deleted file mode 100644 index fbbb4233..00000000 --- a/crates/adapters/postgres/migrations/0020_kap_v03.sql +++ /dev/null @@ -1,12 +0,0 @@ --- k-ap 0.3: new RemoteActor fields + activity dedup table - -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS bio TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS banner_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS followers_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS following_url TEXT; -ALTER TABLE ap_remote_actors ADD COLUMN IF NOT EXISTS also_known_as TEXT; -- JSON array - -CREATE TABLE IF NOT EXISTS ap_activities ( - id TEXT PRIMARY KEY, - processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); diff --git a/crates/adapters/postgres/migrations/0021_user_display_name.sql b/crates/adapters/postgres/migrations/0021_user_display_name.sql deleted file mode 100644 index 00efce82..00000000 --- a/crates/adapters/postgres/migrations/0021_user_display_name.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT; diff --git a/crates/adapters/postgres/migrations/0022_instance_actor.sql b/crates/adapters/postgres/migrations/0022_instance_actor.sql deleted file mode 100644 index 35774ff3..00000000 --- a/crates/adapters/postgres/migrations/0022_instance_actor.sql +++ /dev/null @@ -1,10 +0,0 @@ -INSERT INTO users (id, username, email, password_hash, created_at, role) -VALUES ( - '00000000-0000-4000-8000-000000000000', - 'instance', - 'noreply@instance.invalid', - '!service-actor-no-login', - NOW(), - 'standard' -) -ON CONFLICT (id) DO NOTHING; diff --git a/crates/adapters/postgres/migrations/0023_watch_events.sql b/crates/adapters/postgres/migrations/0023_watch_events.sql deleted file mode 100644 index eaf0f268..00000000 --- a/crates/adapters/postgres/migrations/0023_watch_events.sql +++ /dev/null @@ -1,28 +0,0 @@ -CREATE TABLE IF NOT EXISTS webhook_tokens ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash TEXT NOT NULL, - provider TEXT NOT NULL, - label TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - last_used_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_webhook_tokens_hash ON webhook_tokens(token_hash); -CREATE INDEX IF NOT EXISTS idx_webhook_tokens_user ON webhook_tokens(user_id); - -CREATE TABLE IF NOT EXISTS watch_events ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - movie_id TEXT REFERENCES movies(id) ON DELETE SET NULL, - title TEXT NOT NULL, - year INTEGER, - external_metadata_id TEXT, - source TEXT NOT NULL, - watched_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_watch_events_user_status ON watch_events(user_id, status); -CREATE INDEX IF NOT EXISTS idx_watch_events_dedup ON watch_events(user_id, external_metadata_id, created_at); diff --git a/crates/adapters/postgres/migrations/0024_wrap_up.sql b/crates/adapters/postgres/migrations/0024_wrap_up.sql deleted file mode 100644 index fbc0b19e..00000000 --- a/crates/adapters/postgres/migrations/0024_wrap_up.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE IF NOT EXISTS wrap_up_records ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT, - start_date DATE NOT NULL, - end_date DATE NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - report_json TEXT, - error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - completed_at TIMESTAMPTZ -); -CREATE INDEX IF NOT EXISTS idx_wrap_up_user ON wrap_up_records (user_id, start_date, end_date); diff --git a/crates/adapters/postgres/migrations/0025_goals.sql b/crates/adapters/postgres/migrations/0025_goals.sql deleted file mode 100644 index dd124513..00000000 --- a/crates/adapters/postgres/migrations/0025_goals.sql +++ /dev/null @@ -1,25 +0,0 @@ -CREATE TABLE IF NOT EXISTS goals ( - id TEXT PRIMARY KEY NOT NULL, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - year BIGINT NOT NULL, - target_count BIGINT NOT NULL, - goal_type TEXT NOT NULL DEFAULT 'movies', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, year) -); -CREATE INDEX IF NOT EXISTS idx_goals_user ON goals(user_id); - -CREATE TABLE IF NOT EXISTS user_settings ( - user_id TEXT PRIMARY KEY NOT NULL REFERENCES users(id) ON DELETE CASCADE, - federate_goals BIGINT NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS remote_goals ( - ap_id TEXT PRIMARY KEY NOT NULL, - actor_url TEXT NOT NULL, - year BIGINT NOT NULL, - target_count BIGINT NOT NULL, - current_count BIGINT NOT NULL DEFAULT 0, - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); -CREATE INDEX IF NOT EXISTS idx_remote_goals_actor ON remote_goals(actor_url); diff --git a/crates/adapters/postgres/migrations/0026_person_enrichment.sql b/crates/adapters/postgres/migrations/0026_person_enrichment.sql deleted file mode 100644 index 4d46f6d6..00000000 --- a/crates/adapters/postgres/migrations/0026_person_enrichment.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE persons ADD COLUMN biography TEXT; -ALTER TABLE persons ADD COLUMN birthday TEXT; -ALTER TABLE persons ADD COLUMN deathday TEXT; -ALTER TABLE persons ADD COLUMN place_of_birth TEXT; -ALTER TABLE persons ADD COLUMN also_known_as TEXT; -ALTER TABLE persons ADD COLUMN homepage TEXT; -ALTER TABLE persons ADD COLUMN imdb_id TEXT; -ALTER TABLE persons ADD COLUMN enriched_at TIMESTAMPTZ; diff --git a/crates/adapters/postgres/migrations/0027_refresh_sessions.sql b/crates/adapters/postgres/migrations/0027_refresh_sessions.sql deleted file mode 100644 index 26e97a95..00000000 --- a/crates/adapters/postgres/migrations/0027_refresh_sessions.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE IF NOT EXISTS refresh_sessions ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - token TEXT NOT NULL UNIQUE, - expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user_id ON refresh_sessions(user_id); -CREATE INDEX IF NOT EXISTS idx_refresh_sessions_expires_at ON refresh_sessions(expires_at); diff --git a/crates/adapters/postgres/migrations/0028_federation_privacy.sql b/crates/adapters/postgres/migrations/0028_federation_privacy.sql deleted file mode 100644 index 4e8ec86d..00000000 --- a/crates/adapters/postgres/migrations/0028_federation_privacy.sql +++ /dev/null @@ -1,2 +0,0 @@ -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; diff --git a/crates/adapters/postgres/migrations/0029_watch_medium.sql b/crates/adapters/postgres/migrations/0029_watch_medium.sql deleted file mode 100644 index 64923703..00000000 --- a/crates/adapters/postgres/migrations/0029_watch_medium.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE reviews ADD COLUMN watch_medium TEXT; diff --git a/crates/adapters/postgres/migrations/0030_review_spoilers.sql b/crates/adapters/postgres/migrations/0030_review_spoilers.sql deleted file mode 100644 index 41cd6e71..00000000 --- a/crates/adapters/postgres/migrations/0030_review_spoilers.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs deleted file mode 100644 index 9c14e8e1..00000000 --- a/crates/adapters/postgres/src/diary.rs +++ /dev/null @@ -1,570 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - DiaryEntry, DiaryFilter, FeedEntry, MovieStats, ReviewHistory, ReviewSortBy, - collections::{PageParams, Paginated}, - }, - ports::DiaryQuery, - value_objects::{MovieId, UserId}, -}; -use futures::stream::BoxStream; -use sqlx::PgPool; - -use crate::models::{DiaryRow, FeedRow, MovieRow, MovieStatsRow, ReviewRow}; - -pub struct PostgresDiaryRepository { - pool: PgPool, -} - -impl PostgresDiaryRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - async fn count_diary_entries(&self, movie_id: Option<&str>) -> Result { - match movie_id { - None => sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews") - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error), - Some(id) => { - sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") - .bind(id) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - } - } - - async fn fetch_all_diary_rows( - &self, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - ORDER BY {} - LIMIT $1 OFFSET $2", - order - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_movie_diary_rows( - &self, - movie_id: &str, - sort: &ReviewSortBy, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let order = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.movie_id = $1 - ORDER BY {} - LIMIT $2 OFFSET $3", - order - ); - sqlx::query_as::<_, DiaryRow>(&sql) - .bind(movie_id) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn count_user_diary_entries( - &self, - user_id: &str, - search: Option<&str>, - include_remote: bool, - ) -> Result { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - let search_clause = if has_search { - " AND m.title ILIKE '%' || $2 || '%'" - } else { - "" - }; - let sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1{remote_clause}{search_clause}" - ); - let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_diary_rows( - &self, - user_id: &str, - sort: &ReviewSortBy, - search: Option<&str>, - include_remote: bool, - limit: i64, - offset: i64, - ) -> Result, DomainError> { - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let order_clause = match sort { - ReviewSortBy::ByRatingDesc => "r.rating DESC, r.watched_at DESC", - ReviewSortBy::ByRatingAsc => "r.rating ASC, r.watched_at ASC", - ReviewSortBy::Ascending => "r.watched_at ASC", - ReviewSortBy::Descending => "r.watched_at DESC", - }; - let remote_clause = if include_remote { - "" - } else { - " AND r.remote_actor_url IS NULL" - }; - - let mut p: i32 = 1; - let search_clause = if has_search { - p += 1; - format!(" AND m.title ILIKE '%' || ${} || '%'", p) - } else { - String::new() - }; - p += 1; - let limit_param = format!("${}", p); - p += 1; - let offset_param = format!("${}", p); - - let sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1{remote_clause}{search_clause} - ORDER BY {order_clause} - LIMIT {limit_param} OFFSET {offset_param}", - ); - - let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); - if has_search { - q = q.bind(search.unwrap()); - } - q.bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } -} - -#[async_trait] -impl DiaryQuery for PostgresDiaryRepository { - async fn query_diary( - &self, - filter: &DiaryFilter, - ) -> Result, DomainError> { - let limit = filter.page.limit as i64; - let offset = filter.page.offset as i64; - - let (total, rows) = match (&filter.movie_id, &filter.user_id) { - (None, None) => tokio::try_join!( - self.count_diary_entries(None), - self.fetch_all_diary_rows(&filter.sort_by, limit, offset) - )?, - (Some(id), None) => { - let id_str = id.value().to_string(); - tokio::try_join!( - self.count_diary_entries(Some(id_str.as_str())), - self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset) - )? - } - (None, Some(uid)) => { - let uid_str = uid.value().to_string(); - let search = filter.search.as_deref(); - let inc = filter.include_remote; - tokio::try_join!( - self.count_user_diary_entries(&uid_str, search, inc), - self.fetch_user_diary_rows( - &uid_str, - &filter.sort_by, - search, - inc, - limit, - offset - ) - )? - } - (Some(_), Some(_)) => { - return Err(DomainError::ValidationError( - "Combined movie_id + user_id filter not supported".into(), - )); - } - }; - - let items = rows - .into_iter() - .map(DiaryRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: filter.page.limit, - offset: filter.page.offset, - }) - } - - async fn query_activity_feed( - &self, - page: &PageParams, - ) -> Result, DomainError> { - self.query_activity_feed_filtered(page, &domain::models::FeedSortBy::Date, None, None) - .await - } - - async fn query_activity_feed_filtered( - &self, - page: &PageParams, - sort_by: &domain::models::FeedSortBy, - search: Option<&str>, - following: Option<&domain::models::FollowingFilter>, - ) -> Result, DomainError> { - use domain::models::FeedSortBy; - - let limit = page.limit as i64; - let offset = page.offset as i64; - let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - - let mut p: i32 = 0; - let mut next_param = || { - p += 1; - format!("${}", p) - }; - - let mut where_parts = vec!["1=1".to_string()]; - - if has_search { - let pn = next_param(); - where_parts.push(format!("m.title ILIKE '%' || {} || '%'", pn)); - } - - if let Some(f) = following { - let local_params: Vec = f.local_user_ids.iter().map(|_| next_param()).collect(); - let remote_params: Vec = - f.remote_actor_urls.iter().map(|_| next_param()).collect(); - - let local_in = if local_params.is_empty() { - "(SELECT NULL::text WHERE false)".to_string() - } else { - local_params.join(", ") - }; - let remote_in = if remote_params.is_empty() { - "(SELECT NULL::text WHERE false)".to_string() - } else { - remote_params.join(", ") - }; - where_parts.push(format!( - "(r.user_id IN ({}) OR r.remote_actor_url IN ({}))", - local_in, remote_in - )); - } - - let limit_param = next_param(); - let offset_param = next_param(); - - let order_clause = match sort_by { - FeedSortBy::Date => "r.watched_at DESC", - FeedSortBy::DateAsc => "r.watched_at ASC", - FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC", - FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC", - }; - - let where_clause = where_parts.join(" AND "); - - let count_sql = format!( - "SELECT COUNT(*) FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE {}", - where_clause - ); - - let select_sql = format!( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers, - CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) - ELSE COALESCE(u.email, r.user_id) END AS user_email - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - LEFT JOIN users u ON u.id = r.user_id - LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE {} - ORDER BY {} - LIMIT {} OFFSET {}", - where_clause, order_clause, limit_param, offset_param - ); - - macro_rules! bind_filter_params { - ($q:expr) => {{ - let mut q = $q; - if has_search { - q = q.bind(search.unwrap()); - } - if let Some(f) = following { - for uid in &f.local_user_ids { - q = q.bind(uid.to_string()); - } - for url in &f.remote_actor_urls { - q = q.bind(url.as_str()); - } - } - q - }}; - } - - let count_q = bind_filter_params!(sqlx::query_scalar::<_, i64>(&count_sql)); - let total = count_q - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let rows_q = bind_filter_params!(sqlx::query_as::<_, FeedRow>(&select_sql)); - let rows = rows_q - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(FeedRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn get_review_history(&self, movie_id: &MovieId) -> Result { - let id_str = movie_id.value().to_string(); - - let movie = sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .ok_or_else(|| DomainError::NotFound(format!("Movie {}", id_str)))? - .into_domain()?; - - let viewings = sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE movie_id = $1 ORDER BY watched_at ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(ReviewRow::into_domain) - .collect::, _>>()?; - - Ok(ReviewHistory::new(movie, viewings)) - } - - async fn get_user_history(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let rows = sqlx::query_as::<_, DiaryRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 - ORDER BY r.watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter().map(DiaryRow::into_domain).collect() - } - - fn stream_user_history( - &self, - user_id: UserId, - ) -> BoxStream<'static, Result> { - 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, - r.watch_medium, - r.spoilers - 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(adapter_common::map_sqlx_error(e)), - }; - } - }) - } - - async fn get_movie_stats(&self, movie_id: &MovieId) -> Result { - let id_str = movie_id.value().to_string(); - sqlx::query_as::<_, MovieStatsRow>( - "SELECT - COUNT(*) AS total_count, - AVG(CAST(rating AS FLOAT)) AS avg_rating, - COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count, - COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1, - COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2, - COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3, - COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4, - COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5 - FROM reviews WHERE movie_id = $1", - ) - .bind(id_str) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - .map(MovieStatsRow::into_domain) - } - - async fn get_movie_social_feed( - &self, - movie_id: &MovieId, - page: &PageParams, - ) -> Result, DomainError> { - let id_str = movie_id.value().to_string(); - let limit = page.limit as i64; - let offset = page.offset as i64; - - let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1") - .bind(&id_str) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let rows = sqlx::query_as::<_, FeedRow>( - "SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, - r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - r.remote_actor_url, - r.watch_medium, - r.spoilers, - CASE WHEN r.remote_actor_url IS NOT NULL THEN COALESCE(a.handle, r.remote_actor_url) - ELSE COALESCE(u.email, r.user_id) END AS user_email - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - LEFT JOIN users u ON u.id = r.user_id - LEFT JOIN ap_remote_actors a ON a.url = r.remote_actor_url - WHERE r.movie_id = $1 - ORDER BY r.watched_at DESC - LIMIT $2 OFFSET $3", - ) - .bind(&id_str) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(FeedRow::into_domain) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn count_local_posts(&self) -> Result { - let count: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL") - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(count as u64) - } -} diff --git a/crates/adapters/postgres/src/goals.rs b/crates/adapters/postgres/src/goals.rs deleted file mode 100644 index 2547e1e5..00000000 --- a/crates/adapters/postgres/src/goals.rs +++ /dev/null @@ -1,189 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{Goal, GoalType}, - ports::{GoalCommand, GoalQuery}, - value_objects::{GoalId, UserId}, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{datetime_to_str, parse_datetime, parse_uuid}; - -pub struct PostgresGoalRepository { - pool: PgPool, -} - -impl PostgresGoalRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl GoalCommand for PostgresGoalRepository { - async fn save(&self, goal: &Goal) -> Result<(), DomainError> { - let id = goal.id().value().to_string(); - let user_id = goal.user_id().value().to_string(); - let year = goal.year() as i64; - let target = goal.target_count() as i64; - let goal_type = goal.goal_type().as_str(); - let created_at = datetime_to_str(goal.created_at()); - - sqlx::query( - "INSERT INTO goals (id, user_id, year, target_count, goal_type, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6::timestamptz)", - ) - .bind(&id) - .bind(&user_id) - .bind(year) - .bind(target) - .bind(goal_type) - .bind(&created_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update(&self, goal: &Goal) -> Result<(), DomainError> { - let id = goal.id().value().to_string(); - let target = goal.target_count() as i64; - - let result = sqlx::query("UPDATE goals SET target_count = $1 WHERE id = $2") - .bind(target) - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound("Goal not found".into())); - } - Ok(()) - } - - async fn delete(&self, id: &GoalId, user_id: &UserId) -> Result<(), DomainError> { - let gid = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM goals WHERE id = $1 AND user_id = $2") - .bind(&gid) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound("Goal not found".into())); - } - Ok(()) - } -} - -#[async_trait] -impl GoalQuery for PostgresGoalRepository { - async fn find_by_user_and_year( - &self, - user_id: &UserId, - year: u16, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let y = year as i64; - - let row = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 AND year = $2", - ) - .bind(&uid) - .bind(y) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| row_to_goal(&r)).transpose() - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, year, target_count, goal_type, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM goals WHERE user_id = $1 ORDER BY year DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_goal).collect() - } -} - -pub(crate) async fn count_reviews_in_year( - pool: &PgPool, - user_id: &UserId, - year: u16, -) -> Result { - let uid = user_id.value().to_string(); - let start = format!("{year}-01-01 00:00:00"); - let end = format!("{}-01-01 00:00:00", year + 1); - - let count: i64 = sqlx::query( - "SELECT COUNT(*) FROM reviews \ - WHERE user_id = $1 \ - AND watched_at >= $2::timestamptz \ - AND watched_at < $3::timestamptz \ - AND remote_actor_url IS NULL", - ) - .bind(&uid) - .bind(&start) - .bind(&end) - .fetch_one(pool) - .await - .map_err(|e| { - tracing::error!("Database error: {:?}", e); - DomainError::InfrastructureError("Database operation failed".into()) - })? - .try_get(0) - .unwrap_or(0); - - Ok(count as u32) -} - -pub(crate) fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result { - let id_str: String = r - .try_get("id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?; - let user_id_str: String = r - .try_get("user_id") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?; - let year: i64 = r - .try_get("year") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?; - let target: i64 = r.try_get("target_count").map_err(|e| { - DomainError::InfrastructureError(format!("Failed to read target_count: {e}")) - })?; - let goal_type_str: String = r - .try_get("goal_type") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?; - let created_at_str: String = r - .try_get("created_at") - .map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?; - - let id = GoalId::from_uuid(parse_uuid(&id_str)?); - let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?); - let goal_type: GoalType = goal_type_str.parse()?; - let created_at = parse_datetime(&created_at_str)?; - - Ok(Goal::from_persistence( - id, - user_id, - year as u16, - target as u32, - goal_type, - created_at, - )) -} diff --git a/crates/adapters/postgres/src/image_ref.rs b/crates/adapters/postgres/src/image_ref.rs deleted file mode 100644 index d532cfb5..00000000 --- a/crates/adapters/postgres/src/image_ref.rs +++ /dev/null @@ -1,66 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - ports::{ImageRefCommand, ImageRefQuery}, -}; -use sqlx::PgPool; -use std::sync::Arc; - -pub struct PostgresImageRefAdapter { - pool: PgPool, -} - -impl PostgresImageRefAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_image_ref(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresImageRefAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl ImageRefCommand for PostgresImageRefAdapter { - async fn swap(&self, old_key: &str, new_key: &str) -> Result<(), DomainError> { - let mut tx = self - .pool - .begin() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - sqlx::query("UPDATE users SET avatar_path = $1 WHERE avatar_path = $2") - .bind(new_key) - .bind(old_key) - .execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - sqlx::query("UPDATE movies SET poster_path = $1 WHERE poster_path = $2") - .bind(new_key) - .bind(old_key) - .execute(&mut *tx) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - tx.commit() - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) - } -} - -#[async_trait] -impl ImageRefQuery for PostgresImageRefAdapter { - async fn list_keys(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT avatar_path FROM users WHERE avatar_path IS NOT NULL - UNION - SELECT poster_path FROM movies WHERE poster_path IS NOT NULL", - ) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows.into_iter().map(|(k,)| k).collect()) - } -} diff --git a/crates/adapters/postgres/src/import_profile.rs b/crates/adapters/postgres/src/import_profile.rs deleted file mode 100644 index 3d033c84..00000000 --- a/crates/adapters/postgres/src/import_profile.rs +++ /dev/null @@ -1,212 +0,0 @@ -use async_trait::async_trait; -use chrono::NaiveDateTime; -use domain::{ - errors::DomainError, - models::{ - FieldMapping, ImportProfile, - import::{DomainField, Transform}, - }, - ports::ImportProfileRepository, - value_objects::{ImportProfileId, UserId}, -}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: match &m.domain_field { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - }, - transform: match &m.transform { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - }, - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: match j.domain_field { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - }, - transform: match j.transform { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - }, - } -} - -fn serialize_mappings(ms: &[FieldMapping]) -> Result { - serde_json::to_string(&ms.iter().map(mapping_to_json).collect::>()) - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn deserialize_mappings(s: &str) -> Result, DomainError> { - let js: Vec = - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(js.into_iter().map(mapping_from_json).collect()) -} - -pub struct PostgresImportProfileRepository { - pool: PgPool, -} - -impl PostgresImportProfileRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ImportProfileRepository for PostgresImportProfileRepository { - async fn save(&self, p: &ImportProfile) -> Result<(), DomainError> { - let id = p.id.value().to_string(); - let user_id = p.user_id.value().to_string(); - let field_mappings = serialize_mappings(&p.field_mappings)?; - sqlx::query( - "INSERT INTO import_profiles (id, user_id, name, field_mappings, created_at) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, field_mappings = EXCLUDED.field_mappings", - ) - .bind(&id).bind(&user_id).bind(&p.name).bind(&field_mappings).bind(p.created_at) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } - - let rows = sqlx::query_as::<_, Row>( - "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.into_iter() - .map(|r| { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - r.user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - }) - }) - .collect() - } - - async fn get( - &self, - id: &ImportProfileId, - user_id: &UserId, - ) -> Result, DomainError> { - let id_str = id.value().to_string(); - let uid_str = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - name: String, - field_mappings: String, - created_at: NaiveDateTime, - } - - let row = sqlx::query_as::<_, Row>( - "SELECT id, user_id, name, field_mappings, created_at FROM import_profiles WHERE id = $1 AND user_id = $2", - ) - .bind(&id_str).bind(&uid_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| { - Ok(ImportProfile { - id: ImportProfileId::from_uuid( - r.id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - r.user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - name: r.name, - field_mappings: deserialize_mappings(&r.field_mappings)?, - created_at: r.created_at, - }) - }) - .transpose() - } - - async fn delete(&self, id: &ImportProfileId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - sqlx::query("DELETE FROM import_profiles WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } -} diff --git a/crates/adapters/postgres/src/import_session.rs b/crates/adapters/postgres/src/import_session.rs deleted file mode 100644 index 17733e58..00000000 --- a/crates/adapters/postgres/src/import_session.rs +++ /dev/null @@ -1,387 +0,0 @@ -use async_trait::async_trait; -use chrono::NaiveDateTime; -use domain::{ - errors::DomainError, - models::{ - AnnotatedRow, FieldMapping, ImportSession, ParsedFile, - import::{DomainField, ImportRow, RowResult, Transform}, - }, - ports::ImportSessionRepository, - value_objects::{ImportSessionId, UserId}, -}; -use serde::{Deserialize, Serialize}; -use sqlx::PgPool; - -// ── serde mirror structs ── - -#[derive(Serialize, Deserialize, Default)] -struct ParsedFileJson { - columns: Vec, - rows: Vec>, -} - -#[derive(Serialize, Deserialize)] -enum DomainFieldJson { - Title, - ReleaseYear, - Director, - Rating, - WatchedAt, - Comment, - ExternalMetadataId, -} - -#[derive(Serialize, Deserialize)] -enum TransformJson { - RatingScale(f64), - DateFormat(String), - Identity, -} - -#[derive(Serialize, Deserialize)] -struct FieldMappingJson { - source_column: String, - domain_field: DomainFieldJson, - transform: TransformJson, -} - -#[derive(Serialize, Deserialize, Default)] -struct ImportRowJson { - #[serde(skip_serializing_if = "Option::is_none")] - title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_year: Option, - #[serde(skip_serializing_if = "Option::is_none")] - director: Option, - #[serde(skip_serializing_if = "Option::is_none")] - rating: Option, - #[serde(skip_serializing_if = "Option::is_none")] - watched_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - comment: Option, - #[serde(skip_serializing_if = "Option::is_none")] - external_metadata_id: Option, -} - -#[derive(Serialize, Deserialize)] -enum RowResultJson { - Valid(ImportRowJson), - Invalid { - errors: Vec, - raw: Vec<(String, String)>, - }, -} - -#[derive(Serialize, Deserialize)] -struct AnnotatedRowJson { - result: RowResultJson, - is_duplicate: bool, -} - -// ── conversion helpers ── - -fn domain_field_to_json(f: &DomainField) -> DomainFieldJson { - match f { - DomainField::Title => DomainFieldJson::Title, - DomainField::ReleaseYear => DomainFieldJson::ReleaseYear, - DomainField::Director => DomainFieldJson::Director, - DomainField::Rating => DomainFieldJson::Rating, - DomainField::WatchedAt => DomainFieldJson::WatchedAt, - DomainField::Comment => DomainFieldJson::Comment, - DomainField::ExternalMetadataId => DomainFieldJson::ExternalMetadataId, - } -} - -fn domain_field_from_json(j: DomainFieldJson) -> DomainField { - match j { - DomainFieldJson::Title => DomainField::Title, - DomainFieldJson::ReleaseYear => DomainField::ReleaseYear, - DomainFieldJson::Director => DomainField::Director, - DomainFieldJson::Rating => DomainField::Rating, - DomainFieldJson::WatchedAt => DomainField::WatchedAt, - DomainFieldJson::Comment => DomainField::Comment, - DomainFieldJson::ExternalMetadataId => DomainField::ExternalMetadataId, - } -} - -fn transform_to_json(t: &Transform) -> TransformJson { - match t { - Transform::RatingScale(f) => TransformJson::RatingScale(*f), - Transform::DateFormat(s) => TransformJson::DateFormat(s.clone()), - Transform::Identity => TransformJson::Identity, - } -} - -fn transform_from_json(j: TransformJson) -> Transform { - match j { - TransformJson::RatingScale(f) => Transform::RatingScale(f), - TransformJson::DateFormat(s) => Transform::DateFormat(s), - TransformJson::Identity => Transform::Identity, - } -} - -fn mapping_to_json(m: &FieldMapping) -> FieldMappingJson { - FieldMappingJson { - source_column: m.source_column.clone(), - domain_field: domain_field_to_json(&m.domain_field), - transform: transform_to_json(&m.transform), - } -} - -fn mapping_from_json(j: FieldMappingJson) -> FieldMapping { - FieldMapping { - source_column: j.source_column, - domain_field: domain_field_from_json(j.domain_field), - transform: transform_from_json(j.transform), - } -} - -fn import_row_to_json(r: &ImportRow) -> ImportRowJson { - ImportRowJson { - title: r.title.clone(), - release_year: r.release_year.clone(), - director: r.director.clone(), - rating: r.rating.clone(), - watched_at: r.watched_at.clone(), - comment: r.comment.clone(), - external_metadata_id: r.external_metadata_id.clone(), - } -} - -fn import_row_from_json(j: ImportRowJson) -> ImportRow { - ImportRow { - title: j.title, - release_year: j.release_year, - director: j.director, - rating: j.rating, - watched_at: j.watched_at, - comment: j.comment, - external_metadata_id: j.external_metadata_id, - } -} - -fn annotated_to_json(a: &AnnotatedRow) -> AnnotatedRowJson { - AnnotatedRowJson { - result: match &a.result { - RowResult::Valid(row) => RowResultJson::Valid(import_row_to_json(row)), - RowResult::Invalid { errors, raw } => RowResultJson::Invalid { - errors: errors.clone(), - raw: raw.clone(), - }, - }, - is_duplicate: a.is_duplicate, - } -} - -fn annotated_from_json(j: AnnotatedRowJson) -> AnnotatedRow { - AnnotatedRow { - result: match j.result { - RowResultJson::Valid(row) => RowResult::Valid(import_row_from_json(row)), - RowResultJson::Invalid { errors, raw } => RowResult::Invalid { errors, raw }, - }, - is_duplicate: j.is_duplicate, - } -} - -fn ser(v: &T) -> Result { - serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -fn de Deserialize<'de>>(s: &str) -> Result { - serde_json::from_str(s).map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -// ── repository ── - -pub struct PostgresImportSessionRepository { - pool: PgPool, -} - -impl PostgresImportSessionRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - fn serialize_session( - s: &ImportSession, - ) -> Result<(String, Option, Option), DomainError> { - let parsed = s - .parsed_file - .as_ref() - .map(|f| { - ser(&ParsedFileJson { - columns: f.columns.clone(), - rows: f.rows.clone(), - }) - }) - .transpose()? - .unwrap_or_default(); - let mappings = s - .field_mappings - .as_ref() - .map(|ms| ser(&ms.iter().map(mapping_to_json).collect::>())) - .transpose()?; - let results = s - .row_results - .as_ref() - .map(|rs| ser(&rs.iter().map(annotated_to_json).collect::>())) - .transpose()?; - Ok((parsed, mappings, results)) - } - - fn deserialize_session( - id: String, - user_id: String, - parsed_data: String, - field_mappings: Option, - row_results: Option, - created_at: NaiveDateTime, - expires_at: NaiveDateTime, - ) -> Result { - let parsed_file = if parsed_data.is_empty() { - None - } else { - let j: ParsedFileJson = de(&parsed_data)?; - Some(ParsedFile { - columns: j.columns, - rows: j.rows, - }) - }; - let field_mappings = field_mappings - .as_deref() - .map(|s| -> Result, DomainError> { - let js: Vec = de(s)?; - Ok(js.into_iter().map(mapping_from_json).collect()) - }) - .transpose()?; - let row_results = row_results - .as_deref() - .map(|s| -> Result, DomainError> { - let js: Vec = de(s)?; - Ok(js.into_iter().map(annotated_from_json).collect()) - }) - .transpose()?; - Ok(ImportSession { - id: ImportSessionId::from_uuid( - id.parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - user_id: UserId::from_uuid( - user_id - .parse::() - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - ), - parsed_file, - field_mappings, - row_results, - created_at, - expires_at, - }) - } -} - -#[async_trait] -impl ImportSessionRepository for PostgresImportSessionRepository { - async fn create(&self, s: &ImportSession) -> Result<(), DomainError> { - let id = s.id.value().to_string(); - let user_id = s.user_id.value().to_string(); - let (parsed_data, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query( - "INSERT INTO import_sessions (id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(&id).bind(&user_id).bind(&parsed_data) - .bind(&field_mappings).bind(&row_results) - .bind(s.created_at).bind(s.expires_at) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn get( - &self, - id: &ImportSessionId, - user_id: &UserId, - ) -> Result, DomainError> { - let id_str = id.value().to_string(); - let uid_str = user_id.value().to_string(); - - #[derive(sqlx::FromRow)] - struct Row { - id: String, - user_id: String, - parsed_data: String, - field_mappings: Option, - row_results: Option, - created_at: NaiveDateTime, - expires_at: NaiveDateTime, - } - - let row = sqlx::query_as::<_, Row>( - "SELECT id, user_id, parsed_data, field_mappings, row_results, created_at, expires_at - FROM import_sessions WHERE id = $1 AND user_id = $2", - ) - .bind(&id_str) - .bind(&uid_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(|r| { - Self::deserialize_session( - r.id, - r.user_id, - r.parsed_data, - r.field_mappings, - r.row_results, - r.created_at, - r.expires_at, - ) - }) - .transpose() - } - - async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { - let id = s.id.value().to_string(); - let (_, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query( - "UPDATE import_sessions SET field_mappings = $1, row_results = $2 WHERE id = $3", - ) - .bind(&field_mappings) - .bind(&row_results) - .bind(&id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - sqlx::query("DELETE FROM import_sessions WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < NOW()") - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_expired_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - let uid = user_id.value().to_string(); - sqlx::query("DELETE FROM import_sessions WHERE user_id = $1 AND expires_at < NOW()") - .bind(&uid) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(adapter_common::map_sqlx_error) - } -} diff --git a/crates/adapters/postgres/src/insights.rs b/crates/adapters/postgres/src/insights.rs deleted file mode 100644 index 09bfe971..00000000 --- a/crates/adapters/postgres/src/insights.rs +++ /dev/null @@ -1,81 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use domain::{ - errors::DomainError, models::insights::WatchRow, ports::InsightsQuery, value_objects::UserId, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{parse_datetime, parse_uuid}; - -use crate::wrapup::fetch_genres_pg; - -/// Postgres twin of `SqliteInsightsQuery` — same shape, `$n` binds, and the -/// same thin "rows in, rows out" contract (ADR-0010). -pub struct PostgresInsightsQuery { - pool: PgPool, -} - -impl PostgresInsightsQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl InsightsQuery for PostgresInsightsQuery { - async fn get_watch_rows(&self, user_id: &UserId) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, rating, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - watch_medium \ - FROM reviews \ - WHERE user_id = $1 AND remote_actor_url IS NULL \ - ORDER BY watched_at ASC", - ) - .bind(user_id.value().to_string()) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut movie_ids: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for row in &rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - if seen.insert(mid.clone()) { - movie_ids.push(mid); - } - } - - let genres_map: HashMap> = - fetch_genres_pg(&self.pool, &movie_ids).await?; - - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let movie_id: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let rating: i64 = row - .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let watch_medium: Option = row - .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; - - result.push(WatchRow { - movie_id: parse_uuid(&movie_id)?, - rating: rating as u8, - watched_at: parse_datetime(&watched_at)?, - watch_medium, - genres: genres_map.get(&movie_id).cloned().unwrap_or_default(), - }); - } - - Ok(result) - } -} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs deleted file mode 100644 index 28c2438e..00000000 --- a/crates/adapters/postgres/src/lib.rs +++ /dev/null @@ -1,127 +0,0 @@ -use domain::errors::DomainError; -use sqlx::PgPool; - -mod diary; -mod goals; -mod image_ref; -mod import_profile; -mod import_session; -mod insights; -mod models; -mod movie; -mod movie_dedup; -mod persons; -mod profile; -mod profile_fields; -mod refresh_sessions; -mod review; -mod stats; -mod user_settings; -mod users; -mod watch_event; -mod watchlist; -mod wrapup; - -pub use diary::PostgresDiaryRepository; -pub use image_ref::{PostgresImageRefAdapter, create_image_ref}; -pub use import_profile::PostgresImportProfileRepository; -pub use import_session::PostgresImportSessionRepository; -pub use insights::PostgresInsightsQuery; -pub use movie::PostgresMovieRepository; -pub use movie_dedup::PostgresMovieDeduplicator; -pub use persons::{PostgresPersonAdapter, create_person_adapter}; -pub use postgres_social::PostgresApContentQuery; -pub use profile::PostgresMovieProfileRepository; -pub use profile_fields::PostgresProfileFieldsRepository; -pub use refresh_sessions::PostgresRefreshSessionAdapter; -pub use review::PostgresReviewRepository; -pub use stats::PostgresStatsRepository; -pub use users::PostgresUserRepository; -pub use watch_event::{PostgresWatchEventRepository, PostgresWebhookTokenRepository}; -pub use watchlist::PostgresWatchlistRepository; -pub use wrapup::{PostgresWrapUpRepository, PostgresWrapUpStatsQuery}; - -pub async fn migrate(pool: &PgPool) -> Result<(), DomainError> { - sqlx::migrate!("./migrations") - .set_ignore_missing(true) - .run(pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string())) -} - -pub fn create_profile_fields_repo( - pool: sqlx::PgPool, -) -> std::sync::Arc { - std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool)) -} - -pub struct PostgresWireOutput { - pub pool: PgPool, - pub movie_command: std::sync::Arc, - pub movie_query: std::sync::Arc, - pub review: std::sync::Arc, - pub diary: std::sync::Arc, - pub stats: std::sync::Arc, - pub user: std::sync::Arc, - pub import_session: std::sync::Arc, - pub import_profile: std::sync::Arc, - pub movie_profile: std::sync::Arc, - pub watchlist: std::sync::Arc, - pub ap_content: std::sync::Arc, - pub wrapup_repo: std::sync::Arc, - pub wrapup_stats: std::sync::Arc, - pub insights: std::sync::Arc, - pub goal_command: std::sync::Arc, - pub goal_query: std::sync::Arc, - pub user_settings: std::sync::Arc, - pub federation_settings: std::sync::Arc, - pub remote_goal: std::sync::Arc, - pub deduplicator: std::sync::Arc, -} - -pub async fn wire(database_url: &str) -> anyhow::Result { - use anyhow::Context; - - let pool = sqlx::PgPool::connect(database_url) - .await - .context("Failed to connect to PostgreSQL database")?; - - migrate(&pool) - .await - .map_err(|e| anyhow::anyhow!("{e}")) - .context("Database migration failed")?; - - let user_settings_repo = std::sync::Arc::new( - user_settings::PostgresUserSettingsRepository::new(pool.clone()), - ); - - let movie_repo = std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())); - - Ok(PostgresWireOutput { - pool: pool.clone(), - movie_command: movie_repo.clone() as _, - movie_query: movie_repo as _, - review: std::sync::Arc::new(PostgresReviewRepository::new(pool.clone())) as _, - diary: std::sync::Arc::new(PostgresDiaryRepository::new(pool.clone())) as _, - stats: std::sync::Arc::new(PostgresStatsRepository::new(pool.clone())) as _, - user: std::sync::Arc::new(PostgresUserRepository::new(pool.clone())) as _, - import_session: std::sync::Arc::new(PostgresImportSessionRepository::new(pool.clone())) - as _, - import_profile: std::sync::Arc::new(PostgresImportProfileRepository::new(pool.clone())) - as _, - movie_profile: std::sync::Arc::new(PostgresMovieProfileRepository::new(pool.clone())) as _, - watchlist: std::sync::Arc::new(PostgresWatchlistRepository::new(pool.clone())) as _, - ap_content: std::sync::Arc::new(PostgresApContentQuery::new(pool.clone())) as _, - wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _, - wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _, - insights: std::sync::Arc::new(PostgresInsightsQuery::new(pool.clone())) as _, - goal_command: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, - goal_query: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, - user_settings: std::sync::Arc::clone(&user_settings_repo) as _, - federation_settings: user_settings_repo as _, - remote_goal: std::sync::Arc::new(postgres_social::PostgresRemoteGoalRepository::new( - pool.clone(), - )) as _, - deduplicator: std::sync::Arc::new(PostgresMovieDeduplicator::new(pool)) as _, - }) -} diff --git a/crates/adapters/postgres/src/models.rs b/crates/adapters/postgres/src/models.rs deleted file mode 100644 index fd098df8..00000000 --- a/crates/adapters/postgres/src/models.rs +++ /dev/null @@ -1,292 +0,0 @@ -use adapter_common::{ - movie_row_to_domain, movie_stats_to_domain, movie_summary_to_domain, review_row_to_domain, - user_summary_to_domain, -}; -use domain::{ - errors::DomainError, - models::{DiaryEntry, FeedEntry, Movie, MovieSummary, Review}, -}; - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, -} - -impl MovieRow { - pub fn into_domain(self) -> Result { - movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieSummaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub genres: Option>, - pub runtime_minutes: Option, - pub original_language: Option, - pub overview: Option, - pub collection_name: Option, -} - -impl MovieSummaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - Ok(movie_summary_to_domain( - movie, - self.genres.unwrap_or_default(), - self.runtime_minutes, - self.original_language, - self.overview, - self.collection_name, - )) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ReviewRow { - pub id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl ReviewRow { - pub fn into_domain(self) -> Result { - review_row_to_domain( - self.id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DiaryRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, -} - -impl DiaryRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - Ok(DiaryEntry::new(movie, review)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct FeedRow { - pub id: String, - pub external_metadata_id: Option, - pub title: String, - pub release_year: i64, - pub director: Option, - pub poster_path: Option, - pub review_id: String, - pub movie_id: String, - pub user_id: String, - pub rating: i64, - pub comment: Option, - pub watched_at: String, - pub created_at: String, - pub remote_actor_url: Option, - pub watch_medium: Option, - pub spoilers: bool, - pub user_email: String, -} - -impl FeedRow { - pub fn into_domain(self) -> Result { - let movie = movie_row_to_domain( - self.id, - self.external_metadata_id, - self.title, - self.release_year, - self.director, - self.poster_path, - )?; - let review = review_row_to_domain( - self.review_id, - self.movie_id, - self.user_id, - self.rating, - self.comment, - self.watched_at, - self.created_at, - self.remote_actor_url, - self.watch_medium, - self.spoilers, - )?; - let diary = DiaryEntry::new(movie, review); - Ok(FeedEntry::new(diary, self.user_email)) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MovieStatsRow { - pub total_count: i64, - pub avg_rating: Option, - pub federated_count: i64, - pub rating_1: i64, - pub rating_2: i64, - pub rating_3: i64, - pub rating_4: i64, - pub rating_5: i64, -} - -impl MovieStatsRow { - pub fn into_domain(self) -> domain::models::MovieStats { - movie_stats_to_domain( - self.total_count, - self.avg_rating, - self.federated_count, - [ - self.rating_1, - self.rating_2, - self.rating_3, - self.rating_4, - self.rating_5, - ], - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserSummaryRow { - pub id: String, - pub email: String, - pub username: String, - pub display_name: Option, - pub total_movies: i64, - pub avg_rating: Option, - pub avatar_path: Option, -} - -impl UserSummaryRow { - pub fn into_domain(self) -> Result { - user_summary_to_domain( - self.id, - self.email, - self.username, - self.display_name, - self.total_movies, - self.avg_rating, - self.avatar_path, - ) - } -} - -#[derive(sqlx::FromRow)] -pub(crate) struct UserTotalsRow { - pub total: i64, - pub avg_rating: Option, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct DirectorCountRow { - pub director: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct MonthlyRatingRow { - pub month: String, - pub avg_rating: f64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct GenreCountRow { - pub genre: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct RatingDistRow { - pub rating: i64, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct WatchMediumCountRow { - pub watch_medium: String, - pub count: i64, -} - -#[derive(sqlx::FromRow)] -pub(crate) struct ActorAppearanceRow { - pub tmdb_person_id: i64, - pub name: String, - pub profile_path: Option, - pub billing_order: i32, - pub movie_id: String, -} diff --git a/crates/adapters/postgres/src/movie.rs b/crates/adapters/postgres/src/movie.rs deleted file mode 100644 index 8c75fada..00000000 --- a/crates/adapters/postgres/src/movie.rs +++ /dev/null @@ -1,253 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - Movie, MovieFilter, MovieSummary, - collections::{PageParams, Paginated}, - }, - ports::{MovieCommand, MovieQuery}, - value_objects::{ExternalMetadataId, MovieId, MovieTitle, ReleaseYear}, -}; -use sqlx::PgPool; - -use crate::models::{MovieRow, MovieSummaryRow}; - -pub struct PostgresMovieRepository { - pool: PgPool, -} - -impl PostgresMovieRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieCommand for PostgresMovieRepository { - async fn upsert_movie(&self, movie: &Movie) -> Result<(), DomainError> { - let id = movie.id().value().to_string(); - let external_metadata_id = movie.external_metadata_id().map(|e| e.value().to_string()); - let title = movie.title().value(); - let release_year = movie.release_year().value() as i64; - let director = movie.director(); - let poster_path = movie.poster_path().map(|p| p.value().to_string()); - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = excluded.external_metadata_id, - title = excluded.title, - release_year = excluded.release_year, - director = excluded.director, - poster_path = excluded.poster_path", - ) - .bind(&id) - .bind(&external_metadata_id) - .bind(title) - .bind(release_year) - .bind(director) - .bind(&poster_path) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_movie(&self, movie_id: &MovieId) -> Result<(), DomainError> { - let id = movie_id.value().to_string(); - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl MovieQuery for PostgresMovieRepository { - async fn get_movie_by_external_id( - &self, - external_metadata_id: &ExternalMetadataId, - ) -> Result, DomainError> { - let id = external_metadata_id.value(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result, DomainError> { - let id = movie_id.value().to_string(); - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(MovieRow::into_domain) - .transpose() - } - - async fn get_movies_by_title_and_year( - &self, - title: &MovieTitle, - year: &ReleaseYear, - ) -> Result, DomainError> { - let title = title.value(); - let year = year.value() as i64; - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE title = $1 AND release_year = $2", - ) - .bind(title) - .bind(year) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(MovieRow::into_domain) - .collect() - } - - async fn existing_external_ids( - &self, - ids: &[ExternalMetadataId], - ) -> Result, DomainError> { - if ids.is_empty() { - return Ok(Default::default()); - } - let vals: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT external_metadata_id FROM movies WHERE external_metadata_id = ANY($1)", - ) - .bind(&vals) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(rows.into_iter().map(|(id,)| id).collect()) - } - - async fn existing_title_year_pairs( - &self, - pairs: &[(MovieTitle, ReleaseYear)], - ) -> Result, DomainError> { - if pairs.is_empty() { - return Ok(Default::default()); - } - let titles: Vec<&str> = pairs.iter().map(|(t, _)| t.value()).collect(); - let years: Vec = pairs.iter().map(|(_, y)| y.value() as i64).collect(); - use sqlx::Row; - let rows = sqlx::query( - "SELECT DISTINCT m.title, m.release_year FROM movies m \ - INNER JOIN unnest($1::text[], $2::bigint[]) AS p(title, release_year) \ - ON m.title = p.title AND m.release_year = p.release_year", - ) - .bind(&titles) - .bind(&years) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(rows - .into_iter() - .map(|r| { - let t: String = r.get("title"); - let y: i64 = r.get("release_year"); - (t, y as u16) - }) - .collect()) - } - - async fn list_movies( - &self, - page: &PageParams, - filter: &MovieFilter, - ) -> Result, DomainError> { - use sqlx::Row; - let limit = page.limit as i64; - let offset = page.offset as i64; - let pattern = filter - .search - .as_deref() - .map(|s| format!("%{}%", s.to_lowercase())); - let genre = filter.genre.as_deref(); - let language = filter.language.as_deref(); - - let rows: Vec = sqlx::query_as( - "SELECT \ - m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ - p.overview, p.runtime_minutes, p.original_language, p.collection_name, \ - array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \ - FROM movies m \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - LEFT JOIN movie_genres g ON g.movie_id = m.id \ - WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ - AND ($2::text IS NULL OR p.original_language = $2) \ - AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \ - GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \ - p.overview, p.runtime_minutes, p.original_language, p.collection_name \ - ORDER BY m.title ASC \ - LIMIT $4 OFFSET $5", - ) - .bind(&pattern) - .bind(language) - .bind(genre) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let total: i64 = sqlx::query( - "SELECT COUNT(DISTINCT m.id) \ - FROM movies m \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \ - AND ($2::text IS NULL OR p.original_language = $2) \ - AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3)))", - ) - .bind(&pattern) - .bind(language) - .bind(genre) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .try_get(0) - .unwrap_or(0); - - let items = rows - .into_iter() - .map(|r| r.into_domain()) - .collect::, _>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn list_movies_with_external_id(&self) -> Result, DomainError> { - sqlx::query_as::<_, MovieRow>( - "SELECT id, external_metadata_id, title, release_year, director, poster_path - FROM movies WHERE external_metadata_id IS NOT NULL", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| r.into_domain()) - .collect() - } -} diff --git a/crates/adapters/postgres/src/movie_dedup.rs b/crates/adapters/postgres/src/movie_dedup.rs deleted file mode 100644 index 8d0d2e89..00000000 --- a/crates/adapters/postgres/src/movie_dedup.rs +++ /dev/null @@ -1,147 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, models::Movie, ports::MovieDeduplicator, value_objects::MovieId, -}; -use sqlx::PgPool; - -pub struct PostgresMovieDeduplicator { - pool: PgPool, -} - -impl PostgresMovieDeduplicator { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieDeduplicator for PostgresMovieDeduplicator { - async fn merge_into_canonical( - &self, - old_id: &MovieId, - canonical: &Movie, - ) -> Result { - let old = old_id.value().to_string(); - let new = canonical.id().value().to_string(); - let ext_id = canonical - .external_metadata_id() - .map(|id| id.value().to_string()); - let title = canonical.title().value().to_string(); - let year = canonical.release_year().value() as i64; - let director = canonical.director().map(str::to_string); - let poster = canonical.poster_path().map(|p| p.value().to_string()); - - let mut tx = self - .pool - .begin() - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movies (id, external_metadata_id, title, release_year, director, poster_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_metadata_id = COALESCE(EXCLUDED.external_metadata_id, movies.external_metadata_id), - poster_path = COALESCE(EXCLUDED.poster_path, movies.poster_path)", - ) - .bind(&new).bind(&ext_id).bind(&title).bind(year).bind(&director).bind(&poster) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - - let reviews = sqlx::query("UPDATE reviews SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let watchlist = - sqlx::query("UPDATE watchlist_entries SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let watch_events = sqlx::query("UPDATE watch_events SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - let profiles = sqlx::query("UPDATE movie_profiles SET movie_id = $1 WHERE movie_id = $2") - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)? - .rows_affected(); - - sqlx::query( - "INSERT INTO movie_genres (movie_id, tmdb_id, name) - SELECT $1, tmdb_id, name FROM movie_genres WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ) - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_keywords (movie_id, tmdb_id, name) - SELECT $1, tmdb_id, name FROM movie_keywords WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ) - .bind(&new) - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_cast (movie_id, tmdb_person_id, name, character, billing_order, profile_path) - SELECT $1, tmdb_person_id, name, character, billing_order, profile_path FROM movie_cast WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - "INSERT INTO movie_crew (movie_id, tmdb_person_id, name, job, department, profile_path) - SELECT $1, tmdb_person_id, name, job, department, profile_path FROM movie_crew WHERE movie_id = $2 - ON CONFLICT DO NOTHING", - ).bind(&new).bind(&old).execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query("DELETE FROM movies WHERE id = $1") - .bind(&old) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - tx.commit().await.map_err(adapter_common::map_sqlx_error)?; - - Ok(reviews + watchlist + watch_events + profiles) - } -} diff --git a/crates/adapters/postgres/src/persons.rs b/crates/adapters/postgres/src/persons.rs deleted file mode 100644 index 03b9a42b..00000000 --- a/crates/adapters/postgres/src/persons.rs +++ /dev/null @@ -1,339 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - CastCredit, CrewCredit, ExternalPersonId, Person, PersonCredits, PersonEnrichmentData, - PersonId, - }, - ports::{PersonCommand, PersonQuery}, - value_objects::MovieId, -}; -use sqlx::PgPool; -use std::sync::Arc; - -pub struct PostgresPersonAdapter { - pool: PgPool, -} - -impl PostgresPersonAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -pub fn create_person_adapter(pool: PgPool) -> (Arc, Arc) { - let adapter = Arc::new(PostgresPersonAdapter::new(pool)); - ( - Arc::clone(&adapter) as Arc, - adapter as Arc, - ) -} - -#[async_trait] -impl PersonCommand for PostgresPersonAdapter { - async fn upsert_batch(&self, persons: &[Person]) -> Result<(), DomainError> { - for person in persons { - let tmdb_id = person.external_id().tmdb_id(); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(id) DO UPDATE SET - external_id = EXCLUDED.external_id, - tmdb_person_id = EXCLUDED.tmdb_person_id, - name = EXCLUDED.name, - known_for_department = EXCLUDED.known_for_department, - profile_path = EXCLUDED.profile_path", - ) - .bind(person.id().value().to_string()) - .bind(person.external_id().value()) - .bind(tmdb_id) - .bind(person.name()) - .bind(person.known_for_department()) - .bind(person.profile_path()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - Ok(()) - } - - async fn backfill_from_credits_batch( - &self, - batch_size: u32, - ) -> Result<(u64, bool), DomainError> { - #[derive(sqlx::FromRow)] - struct MissingPerson { - tmdb_person_id: i64, - name: String, - department: Option, - profile_path: Option, - } - - let rows = sqlx::query_as::<_, MissingPerson>( - "SELECT mc.tmdb_person_id, mc.name, 'Acting' AS department, mc.profile_path - FROM movie_cast mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id, mc.name, mc.profile_path - UNION ALL - SELECT mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - FROM movie_crew mc - WHERE NOT EXISTS (SELECT 1 FROM persons WHERE persons.tmdb_person_id = mc.tmdb_person_id) - AND NOT EXISTS (SELECT 1 FROM movie_cast c2 WHERE c2.tmdb_person_id = mc.tmdb_person_id) - GROUP BY mc.tmdb_person_id, mc.name, mc.department, mc.profile_path - LIMIT $1", - ) - .bind(batch_size as i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let has_more = rows.len() as u32 >= batch_size; - let mut count = 0u64; - for row in &rows { - let ext = ExternalPersonId::new(format!("tmdb:{}", row.tmdb_person_id)); - let pid = PersonId::from_external(&ext); - sqlx::query( - "INSERT INTO persons (id, external_id, tmdb_person_id, name, known_for_department, profile_path) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT(tmdb_person_id) DO NOTHING", - ) - .bind(pid.value().to_string()) - .bind(ext.value()) - .bind(row.tmdb_person_id) - .bind(&row.name) - .bind(&row.department) - .bind(&row.profile_path) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - count += 1; - } - Ok((count, has_more)) - } - - async fn update_enrichment( - &self, - id: &PersonId, - data: &PersonEnrichmentData, - ) -> Result<(), DomainError> { - let also_known_as_json = - serde_json::to_string(&data.also_known_as).unwrap_or_else(|_| "[]".into()); - let now = chrono::Utc::now(); - sqlx::query( - "UPDATE persons SET biography = $1, birthday = $2, deathday = $3, place_of_birth = $4, also_known_as = $5, homepage = $6, imdb_id = $7, enriched_at = $8 WHERE id = $9", - ) - .bind(&data.biography) - .bind(data.birthday.map(|d| d.to_string())) - .bind(data.deathday.map(|d| d.to_string())) - .bind(&data.place_of_birth) - .bind(&also_known_as_json) - .bind(&data.homepage) - .bind(&data.imdb_id) - .bind(now) - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl PersonQuery for PostgresPersonAdapter { - async fn get_by_id(&self, id: &PersonId) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE id = $1", - ) - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_by_external_id( - &self, - id: &ExternalPersonId, - ) -> Result, DomainError> { - let row = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons WHERE external_id = $1", - ) - .bind(id.value()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(row.map(PersonRow::into_person)) - } - - async fn get_credits(&self, id: &PersonId) -> Result { - let person = self - .get_by_id(id) - .await? - .ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?; - - let tmdb_id: Option = - sqlx::query_scalar("SELECT tmdb_person_id FROM persons WHERE id = $1") - .bind(id.value().to_string()) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .flatten(); - - let Some(tmdb_id) = tmdb_id else { - return Ok(PersonCredits { - person, - cast: vec![], - crew: vec![], - }); - }; - - #[derive(sqlx::FromRow)] - struct CastRow { - id: String, - title: String, - release_year: Option, - character: String, - poster_path: Option, - } - #[derive(sqlx::FromRow)] - struct CrewRow { - id: String, - title: String, - release_year: Option, - job: String, - department: String, - poster_path: Option, - } - - let cast = sqlx::query_as::<_, CastRow>( - "SELECT m.id, m.title, m.release_year, mc.character, m.poster_path - FROM movie_cast mc JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 ORDER BY mc.billing_order", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CastCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - character: r.character, - poster_path: r.poster_path, - }) - .collect(); - - let crew = sqlx::query_as::<_, CrewRow>( - "SELECT m.id, m.title, m.release_year, mc.job, mc.department, m.poster_path - FROM movie_crew mc JOIN movies m ON m.id = mc.movie_id - WHERE mc.tmdb_person_id = $1 ORDER BY m.title", - ) - .bind(tmdb_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CrewCredit { - movie_id: MovieId::from_uuid(uuid::Uuid::parse_str(&r.id).unwrap_or_default()), - title: r.title, - release_year: r.release_year.map(|y| y as u16), - job: r.job, - department: r.department, - poster_path: r.poster_path, - }) - .collect(); - - Ok(PersonCredits { person, cast, crew }) - } - - async fn list_page(&self, limit: u32, offset: u32) -> Result, DomainError> { - let rows = sqlx::query_as::<_, PersonRow>( - "SELECT id, external_id, name, known_for_department, profile_path, biography, birthday, deathday, place_of_birth, also_known_as, homepage, imdb_id, enriched_at FROM persons ORDER BY id LIMIT $1 OFFSET $2", - ) - .bind(limit as i64) - .bind(offset as i64) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows.into_iter().map(PersonRow::into_person).collect()) - } - - async fn list_orphaned_persons(&self) -> Result, DomainError> { - let rows: Vec<(String,)> = sqlx::query_as( - "SELECT id FROM persons - WHERE NOT EXISTS ( - SELECT 1 FROM movie_cast WHERE movie_cast.tmdb_person_id = persons.tmdb_person_id - ) - AND NOT EXISTS ( - SELECT 1 FROM movie_crew WHERE movie_crew.tmdb_person_id = persons.tmdb_person_id - ) - LIMIT 500", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|(id,)| uuid::Uuid::parse_str(&id).ok().map(PersonId::from_uuid)) - .collect()) - } -} - -// ── Row types ──────────────────────────────────────────────────────────────── - -#[derive(sqlx::FromRow)] -struct PersonRow { - id: String, - external_id: String, - name: String, - known_for_department: Option, - profile_path: Option, - biography: Option, - birthday: Option, - deathday: Option, - place_of_birth: Option, - also_known_as: Option, - homepage: Option, - imdb_id: Option, - enriched_at: Option, -} - -impl PersonRow { - fn into_person(self) -> Person { - let ext = ExternalPersonId::new(self.external_id); - let also_known_as = self - .also_known_as - .and_then(|s| serde_json::from_str::>(&s).ok()) - .unwrap_or_default(); - let birthday = self - .birthday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let deathday = self - .deathday - .and_then(|s| chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d").ok()); - let enriched_at = self - .enriched_at - .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) - .map(|d| d.with_timezone(&chrono::Utc)); - Person::new( - PersonId::from_uuid(uuid::Uuid::parse_str(&self.id).unwrap_or_default()), - ext, - self.name, - self.known_for_department, - self.profile_path, - self.biography, - birthday, - deathday, - self.place_of_birth, - also_known_as, - self.homepage, - self.imdb_id, - enriched_at, - ) - } -} diff --git a/crates/adapters/postgres/src/profile.rs b/crates/adapters/postgres/src/profile.rs deleted file mode 100644 index d713b2d7..00000000 --- a/crates/adapters/postgres/src/profile.rs +++ /dev/null @@ -1,284 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use domain::{ - errors::DomainError, - models::{CastMember, CrewMember, Genre, Keyword, MovieProfile}, - ports::MovieProfileRepository, - value_objects::MovieId, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresMovieProfileRepository { - pool: PgPool, -} - -impl PostgresMovieProfileRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl MovieProfileRepository for PostgresMovieProfileRepository { - async fn upsert(&self, p: &MovieProfile) -> Result<(), DomainError> { - let movie_id = p.movie_id.value().to_string(); - - let mut tx = self - .pool - .begin() - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query( - r#"INSERT INTO movie_profiles - (movie_id, tmdb_id, imdb_id, overview, tagline, runtime_minutes, - budget_usd, revenue_usd, vote_average, vote_count, - original_language, collection_name, enriched_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) - ON CONFLICT(movie_id) DO UPDATE SET - tmdb_id=EXCLUDED.tmdb_id, imdb_id=EXCLUDED.imdb_id, - overview=EXCLUDED.overview, tagline=EXCLUDED.tagline, - runtime_minutes=EXCLUDED.runtime_minutes, - budget_usd=EXCLUDED.budget_usd, revenue_usd=EXCLUDED.revenue_usd, - vote_average=EXCLUDED.vote_average, vote_count=EXCLUDED.vote_count, - original_language=EXCLUDED.original_language, - collection_name=EXCLUDED.collection_name, - enriched_at=EXCLUDED.enriched_at"#, - ) - .bind(&movie_id) - .bind(p.tmdb_id as i64) - .bind(&p.imdb_id) - .bind(&p.overview) - .bind(&p.tagline) - .bind(p.runtime_minutes.map(|v| v as i32)) - .bind(p.budget_usd) - .bind(p.revenue_usd) - .bind(p.vote_average) - .bind(p.vote_count.map(|v| v as i32)) - .bind(&p.original_language) - .bind(&p.collection_name) - .bind(p.enriched_at) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - - sqlx::query("DELETE FROM movie_genres WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for g in &p.genres { - sqlx::query("INSERT INTO movie_genres (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") - .bind(&movie_id).bind(g.tmdb_id as i32).bind(&g.name) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_keywords WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for k in &p.keywords { - sqlx::query("INSERT INTO movie_keywords (movie_id, tmdb_id, name) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING") - .bind(&movie_id).bind(k.tmdb_id as i32).bind(&k.name) - .execute(&mut *tx).await.map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_cast WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for c in &p.cast { - sqlx::query( - "INSERT INTO movie_cast \ - (movie_id, tmdb_person_id, name, character, billing_order, profile_path) \ - VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", - ) - .bind(&movie_id) - .bind(c.tmdb_person_id as i64) - .bind(&c.name) - .bind(&c.character) - .bind(c.billing_order as i32) - .bind(&c.profile_path) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - - sqlx::query("DELETE FROM movie_crew WHERE movie_id = $1") - .bind(&movie_id) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - for cr in &p.crew { - sqlx::query( - "INSERT INTO movie_crew \ - (movie_id, tmdb_person_id, name, job, department, profile_path) \ - VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT DO NOTHING", - ) - .bind(&movie_id) - .bind(cr.tmdb_person_id as i64) - .bind(&cr.name) - .bind(&cr.job) - .bind(&cr.department) - .bind(&cr.profile_path) - .execute(&mut *tx) - .await - .map_err(adapter_common::map_sqlx_error)?; - } - - tx.commit().await.map_err(adapter_common::map_sqlx_error) - } - - async fn get_by_movie_id(&self, id: &MovieId) -> Result, DomainError> { - let movie_id = id.value().to_string(); - - let row = sqlx::query( - "SELECT tmdb_id, imdb_id, overview, tagline, runtime_minutes, budget_usd, - revenue_usd, vote_average, vote_count, original_language, - collection_name, enriched_at - FROM movie_profiles WHERE movie_id = $1", - ) - .bind(&movie_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let row = match row { - Some(r) => r, - None => return Ok(None), - }; - - let enriched_at: DateTime = row - .try_get("enriched_at") - .map_err(|_| DomainError::InfrastructureError("invalid enriched_at".into()))?; - - let genres = sqlx::query("SELECT tmdb_id, name FROM movie_genres WHERE movie_id = $1") - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| Genre { - tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, - name: r.try_get("name").unwrap_or_default(), - }) - .collect(); - - let keywords = sqlx::query("SELECT tmdb_id, name FROM movie_keywords WHERE movie_id = $1") - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| Keyword { - tmdb_id: r.try_get::("tmdb_id").unwrap_or(0) as u32, - name: r.try_get("name").unwrap_or_default(), - }) - .collect(); - - let cast = sqlx::query( - "SELECT tmdb_person_id, name, character, billing_order, profile_path \ - FROM movie_cast WHERE movie_id = $1 ORDER BY billing_order", - ) - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CastMember { - tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, - name: r.try_get("name").unwrap_or_default(), - character: r.try_get("character").unwrap_or_default(), - billing_order: r.try_get::("billing_order").unwrap_or(0) as u32, - profile_path: r - .try_get::, _>("profile_path") - .ok() - .flatten(), - }) - .collect(); - - let crew = sqlx::query( - "SELECT tmdb_person_id, name, job, department, profile_path \ - FROM movie_crew WHERE movie_id = $1", - ) - .bind(&movie_id) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(|r| CrewMember { - tmdb_person_id: r.try_get::("tmdb_person_id").unwrap_or(0) as u64, - name: r.try_get("name").unwrap_or_default(), - job: r.try_get("job").unwrap_or_default(), - department: r.try_get("department").unwrap_or_default(), - profile_path: r - .try_get::, _>("profile_path") - .ok() - .flatten(), - }) - .collect(); - - Ok(Some(MovieProfile { - movie_id: id.clone(), - tmdb_id: row.try_get::("tmdb_id").unwrap_or(0) as u64, - imdb_id: row.try_get::, _>("imdb_id").ok().flatten(), - overview: row.try_get::, _>("overview").ok().flatten(), - tagline: row.try_get::, _>("tagline").ok().flatten(), - runtime_minutes: row - .try_get::, _>("runtime_minutes") - .ok() - .flatten() - .map(|v| v as u32), - budget_usd: row.try_get::, _>("budget_usd").ok().flatten(), - revenue_usd: row.try_get::, _>("revenue_usd").ok().flatten(), - vote_average: row.try_get::, _>("vote_average").ok().flatten(), - vote_count: row - .try_get::, _>("vote_count") - .ok() - .flatten() - .map(|v| v as u32), - original_language: row - .try_get::, _>("original_language") - .ok() - .flatten(), - collection_name: row - .try_get::, _>("collection_name") - .ok() - .flatten(), - genres, - keywords, - cast, - crew, - enriched_at, - })) - } - - async fn list_stale(&self) -> Result, DomainError> { - let threshold = Utc::now() - chrono::Duration::days(30); - let rows = sqlx::query( - r#"SELECT m.id, m.external_metadata_id - FROM movies m - LEFT JOIN movie_profiles p ON p.movie_id = m.id - WHERE m.external_metadata_id IS NOT NULL - AND (p.movie_id IS NULL OR p.enriched_at < $1) - ORDER BY p.enriched_at ASC NULLS FIRST"#, - ) - .bind(threshold) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(rows - .into_iter() - .filter_map(|r| { - let ext_id: Option = r.try_get("external_metadata_id").ok()?; - let ext_id = ext_id?; - let id_str: String = r.try_get("id").ok()?; - let movie_id = id_str.parse::().ok().map(MovieId::from_uuid)?; - Some((movie_id, ext_id)) - }) - .collect()) - } -} diff --git a/crates/adapters/postgres/src/profile_fields.rs b/crates/adapters/postgres/src/profile_fields.rs deleted file mode 100644 index 3b2b0fc5..00000000 --- a/crates/adapters/postgres/src/profile_fields.rs +++ /dev/null @@ -1,74 +0,0 @@ -use async_trait::async_trait; -use sqlx::PgPool; - -use domain::{ - errors::DomainError, models::ProfileField, ports::UserProfileFieldsRepository, - value_objects::UserId, -}; - -pub struct PostgresProfileFieldsRepository { - pool: PgPool, -} - -impl PostgresProfileFieldsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl UserProfileFieldsRepository for PostgresProfileFieldsRepository { - async fn get_fields(&self, user_id: &UserId) -> Result, DomainError> { - let id_str = user_id.value().to_string(); - #[derive(sqlx::FromRow)] - struct Row { - name: String, - value: String, - } - let rows = sqlx::query_as::<_, Row>( - "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(rows - .into_iter() - .map(|r| ProfileField { - name: r.name, - value: r.value, - }) - .collect()) - } - - async fn set_fields( - &self, - user_id: &UserId, - fields: Vec, - ) -> Result<(), DomainError> { - let id_str = user_id.value().to_string(); - - sqlx::query("DELETE FROM user_profile_fields WHERE user_id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - for (i, field) in fields.into_iter().enumerate() { - let id = uuid::Uuid::new_v4().to_string(); - let position = i as i64; - sqlx::query( - "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES ($1, $2, $3, $4, $5)", - ) - .bind(&id) - .bind(&id_str) - .bind(&field.name) - .bind(&field.value) - .bind(position) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - } - Ok(()) - } -} diff --git a/crates/adapters/postgres/src/refresh_sessions.rs b/crates/adapters/postgres/src/refresh_sessions.rs deleted file mode 100644 index dce011a9..00000000 --- a/crates/adapters/postgres/src/refresh_sessions.rs +++ /dev/null @@ -1,108 +0,0 @@ -use async_trait::async_trait; -use chrono::DateTime; -use domain::{ - errors::DomainError, models::RefreshSession, ports::RefreshSessionRepository, - value_objects::UserId, -}; -use sqlx::PgPool; - -pub struct PostgresRefreshSessionAdapter { - pool: PgPool, -} - -impl PostgresRefreshSessionAdapter { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl RefreshSessionRepository for PostgresRefreshSessionAdapter { - async fn create(&self, session: &RefreshSession) -> Result<(), DomainError> { - sqlx::query( - "INSERT INTO refresh_sessions (id, user_id, token, expires_at, created_at) - VALUES ($1, $2, $3, $4, $5)", - ) - .bind(session.id.to_string()) - .bind(session.user_id.value().to_string()) - .bind(&session.token) - .bind(session.expires_at) - .bind(session.created_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn get_by_token(&self, token: &str) -> Result, DomainError> { - let row = sqlx::query_as::<_, RefreshSessionRow>( - "SELECT id, user_id, token, - to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS expires_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') AS created_at - FROM refresh_sessions WHERE token = $1", - ) - .bind(token) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.map(RefreshSessionRow::into_domain).transpose() - } - - async fn revoke(&self, token: &str) -> Result<(), DomainError> { - sqlx::query("DELETE FROM refresh_sessions WHERE token = $1") - .bind(token) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn revoke_all_for_user(&self, user_id: &UserId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM refresh_sessions WHERE user_id = $1") - .bind(user_id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn delete_expired(&self) -> Result { - let result = sqlx::query("DELETE FROM refresh_sessions WHERE expires_at < NOW()") - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[derive(sqlx::FromRow)] -struct RefreshSessionRow { - id: String, - user_id: String, - token: String, - expires_at: String, - created_at: String, -} - -impl RefreshSessionRow { - fn into_domain(self) -> Result { - let id = uuid::Uuid::parse_str(&self.id) - .map_err(|e| DomainError::InfrastructureError(format!("invalid uuid: {e}")))?; - let user_id = uuid::Uuid::parse_str(&self.user_id) - .map_err(|e| DomainError::InfrastructureError(format!("invalid user_id: {e}")))?; - let expires_at = DateTime::parse_from_rfc3339(&self.expires_at) - .map_err(|e| DomainError::InfrastructureError(format!("invalid expires_at: {e}")))? - .with_timezone(&chrono::Utc); - let created_at = DateTime::parse_from_rfc3339(&self.created_at) - .map_err(|e| DomainError::InfrastructureError(format!("invalid created_at: {e}")))? - .with_timezone(&chrono::Utc); - Ok(RefreshSession { - id, - user_id: UserId::from_uuid(user_id), - token: self.token, - expires_at, - created_at, - }) - } -} diff --git a/crates/adapters/postgres/src/review.rs b/crates/adapters/postgres/src/review.rs deleted file mode 100644 index 050187a2..00000000 --- a/crates/adapters/postgres/src/review.rs +++ /dev/null @@ -1,130 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{Review, ReviewSource}, - ports::ReviewRepository, - value_objects::{ReviewId, UserId}, -}; -use sqlx::PgPool; - -use crate::models::ReviewRow; -use adapter_common::datetime_to_str; - -pub struct PostgresReviewRepository { - pool: PgPool, -} - -impl PostgresReviewRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ReviewRepository for PostgresReviewRepository { - async fn save_review(&self, review: &Review) -> Result<(), DomainError> { - let id = review.id().value().to_string(); - let movie_id = review.movie_id().value().to_string(); - let user_id = review.user_id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let created_at = datetime_to_str(review.created_at()); - let remote_actor_url = match review.source() { - ReviewSource::Local => None, - ReviewSource::Remote { actor_url } => Some(actor_url.clone()), - }; - - sqlx::query( - "INSERT INTO reviews (id, movie_id, user_id, rating, comment, watched_at, created_at, remote_actor_url, watch_medium, spoilers) - VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $8, $9, $10)", - ) - .bind(&id) - .bind(&movie_id) - .bind(&user_id) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&created_at) - .bind(&remote_actor_url) - .bind(review.watch_medium().map(|wm| wm.to_string())) - .bind(review.spoilers()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_review_by_id(&self, review_id: &ReviewId) -> Result, DomainError> { - let id = review_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE id = $1", - ) - .bind(&id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .map(ReviewRow::into_domain) - .transpose() - } - - async fn update_review(&self, review: &Review) -> Result<(), DomainError> { - let id = review.id().value().to_string(); - let rating = review.rating().value() as i64; - let comment = review.comment().map(|c| c.value().to_string()); - let watched_at = datetime_to_str(review.watched_at()); - let watch_medium = review.watch_medium().map(|wm| wm.to_string()); - - sqlx::query( - "UPDATE reviews SET rating = $1, comment = $2, watched_at = $3::timestamptz, watch_medium = $4, spoilers = $5 WHERE id = $6", - ) - .bind(rating) - .bind(&comment) - .bind(&watched_at) - .bind(&watch_medium) - .bind(review.spoilers()) - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn delete_review(&self, review_id: &ReviewId) -> Result<(), DomainError> { - let id = review_id.value().to_string(); - sqlx::query("DELETE FROM reviews WHERE id = $1") - .bind(&id) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn get_all_reviews_for_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - sqlx::query_as::<_, ReviewRow>( - "SELECT id, movie_id, user_id, rating, comment, - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, - remote_actor_url, - watch_medium, - spoilers - FROM reviews WHERE user_id = $1 ORDER BY watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(ReviewRow::into_domain) - .collect() - } -} diff --git a/crates/adapters/postgres/src/stats.rs b/crates/adapters/postgres/src/stats.rs deleted file mode 100644 index 5b190680..00000000 --- a/crates/adapters/postgres/src/stats.rs +++ /dev/null @@ -1,239 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - ActorAppearance, DirectorStat, MonthlyRating, UserStats, UserTrends, compute_top_actors, - }, - ports::StatsRepository, - value_objects::UserId, -}; -use sqlx::PgPool; - -use crate::models::{ - ActorAppearanceRow, DirectorCountRow, GenreCountRow, MonthlyRatingRow, RatingDistRow, - UserTotalsRow, WatchMediumCountRow, -}; -use adapter_common::format_year_month; - -pub struct PostgresStatsRepository { - pool: PgPool, -} - -impl PostgresStatsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - async fn fetch_user_totals(&self, user_id: &str) -> Result { - sqlx::query_as::<_, UserTotalsRow>( - r#"SELECT COUNT(DISTINCT movie_id) AS total, - AVG(rating::float) AS avg_rating - FROM reviews WHERE user_id = $1"#, - ) - .bind(user_id) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_favorite_director( - &self, - user_id: &str, - ) -> Result, DomainError> { - sqlx::query_scalar::<_, String>( - "SELECT m.director - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND m.director IS NOT NULL - GROUP BY m.director - ORDER BY COUNT(*) DESC - LIMIT 1", - ) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } - - async fn fetch_user_most_active_month( - &self, - user_id: &str, - ) -> Result, DomainError> { - sqlx::query_scalar::<_, String>( - "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month - FROM reviews - WHERE user_id = $1 - GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') - ORDER BY COUNT(*) DESC - LIMIT 1", - ) - .bind(user_id) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error) - } -} - -#[async_trait] -impl StatsRepository for PostgresStatsRepository { - async fn get_user_stats(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - - let (totals, fav_director, most_active) = tokio::try_join!( - self.fetch_user_totals(&uid), - self.fetch_user_favorite_director(&uid), - self.fetch_user_most_active_month(&uid) - )?; - - let most_active_month = most_active.map(|ym| format_year_month(&ym)); - - Ok(UserStats { - total_movies: totals.total, - avg_rating: totals.avg_rating, - favorite_director: fav_director, - most_active_month, - }) - } - - async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result { - crate::goals::count_reviews_in_year(&self.pool, user_id, year).await - } - - async fn get_user_trends(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - - let (rating_rows, director_rows, genre_rows, rating_dist_rows, medium_rows, actor_rows) = - tokio::try_join!( - sqlx::query_as::<_, MonthlyRatingRow>( - "SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month, - AVG(rating::float) AS avg_rating, - COUNT(*) AS count - FROM reviews - WHERE user_id = $1 AND watched_at >= NOW() - INTERVAL '12 months' - GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') - ORDER BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') ASC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, DirectorCountRow>( - "SELECT m.director AS director, COUNT(*) AS count - FROM reviews r - INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND m.director IS NOT NULL - GROUP BY m.director - ORDER BY COUNT(*) DESC - LIMIT 5" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, GenreCountRow>( - "SELECT mg.name AS genre, COUNT(*) AS count - FROM reviews r - INNER JOIN movie_genres mg ON mg.movie_id = r.movie_id - WHERE r.user_id = $1 - GROUP BY mg.name - ORDER BY COUNT(*) DESC - LIMIT 5" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, RatingDistRow>( - "SELECT rating, COUNT(*) AS count - FROM reviews - WHERE user_id = $1 - GROUP BY rating - ORDER BY rating ASC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, WatchMediumCountRow>( - "SELECT watch_medium, COUNT(*) AS count - FROM reviews - WHERE user_id = $1 AND watch_medium IS NOT NULL - GROUP BY watch_medium - ORDER BY COUNT(*) DESC" - ) - .bind(&uid) - .fetch_all(&self.pool), - sqlx::query_as::<_, ActorAppearanceRow>( - "SELECT mc.tmdb_person_id, mc.name, mc.profile_path, - mc.billing_order, mc.movie_id - FROM reviews r - INNER JOIN movie_cast mc ON mc.movie_id = r.movie_id - WHERE r.user_id = $1 - GROUP BY mc.tmdb_person_id, mc.movie_id, mc.name, mc.profile_path, mc.billing_order" - ) - .bind(&uid) - .fetch_all(&self.pool) - ) - .map_err(adapter_common::map_sqlx_error)?; - - let max_director_count = director_rows.iter().map(|d| d.count).max().unwrap_or(1); - - let monthly_ratings = rating_rows - .into_iter() - .map(|r| MonthlyRating { - month_label: format_year_month(&r.month), - year_month: r.month, - avg_rating: r.avg_rating, - count: r.count, - }) - .collect(); - - let top_directors = director_rows - .into_iter() - .map(|d| DirectorStat { - director: d.director, - count: d.count, - }) - .collect(); - - let top_genres = genre_rows - .into_iter() - .map(|g| domain::models::stats::GenreStat { - genre: g.genre, - count: g.count, - }) - .collect(); - - let rating_distribution = { - let mut dist = [0i64; 5]; - for r in &rating_dist_rows { - let idx = (r.rating as usize).saturating_sub(1).min(4); - dist[idx] = r.count; - } - dist - }; - - let watch_medium_distribution = medium_rows - .into_iter() - .map(|m| domain::models::stats::WatchMediumStat { - medium: m.watch_medium, - count: m.count, - }) - .collect(); - - let top_actors = compute_top_actors( - actor_rows - .into_iter() - .map(|r| ActorAppearance { - tmdb_person_id: r.tmdb_person_id as u64, - name: r.name, - profile_path: r.profile_path, - billing_order: r.billing_order as u32, - movie_id: r.movie_id, - }) - .collect(), - ); - - Ok(UserTrends { - monthly_ratings, - top_directors, - max_director_count, - top_genres, - rating_distribution, - watch_medium_distribution, - top_actors, - }) - } -} diff --git a/crates/adapters/postgres/src/user_settings.rs b/crates/adapters/postgres/src/user_settings.rs deleted file mode 100644 index 37112076..00000000 --- a/crates/adapters/postgres/src/user_settings.rs +++ /dev/null @@ -1,99 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{FederationFlags, UserSettings}, - ports::{UserFederationSettingsQuery, UserSettingsRepository}, - value_objects::UserId, -}; -use sqlx::{PgPool, Row}; - -pub struct PostgresUserSettingsRepository { - pool: PgPool, -} - -impl PostgresUserSettingsRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl UserSettingsRepository for PostgresUserSettingsRepository { - async fn get(&self, user_id: &UserId) -> Result { - 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(adapter_common::map_sqlx_error)?; - - 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(UserSettings::from_persistence( - user_id.clone(), - goals, - reviews, - watchlist, - )) - } - None => Ok(UserSettings::new(user_id.clone())), - } - } - - async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { - let uid = settings.user_id().value().to_string(); - sqlx::query( - "INSERT INTO user_settings (user_id, federate_goals, federate_reviews, federate_watchlist) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id) DO UPDATE \ - SET federate_goals = $2, federate_reviews = $3, federate_watchlist = $4", - ) - .bind(&uid) - .bind(settings.federate_goals()) - .bind(settings.federate_reviews()) - .bind(settings.federate_watchlist()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } -} - -#[async_trait] -impl UserFederationSettingsQuery for PostgresUserSettingsRepository { - async fn get_federation_flags(&self, user_id: &UserId) -> Result { - 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(adapter_common::map_sqlx_error)?; - - 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, - }), - } - } -} diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs deleted file mode 100644 index b11dcd0e..00000000 --- a/crates/adapters/postgres/src/users.rs +++ /dev/null @@ -1,200 +0,0 @@ -use async_trait::async_trait; -use chrono::Utc; -use sqlx::{PgPool, Row}; - -use domain::{ - errors::DomainError, - models::{ProfileField, User, UserRole}, - ports::UserRepository, - value_objects::{Email, PasswordHash, UserId, Username}, -}; - -use super::models::UserSummaryRow; - -pub struct PostgresUserRepository { - pool: PgPool, -} - -impl PostgresUserRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } - - fn parse_role(s: &str) -> UserRole { - match s { - "admin" => UserRole::Admin, - _ => UserRole::Standard, - } - } - - fn row_to_user( - row: &sqlx::postgres::PgRow, - profile_fields: Vec, - ) -> Result { - let id_str: String = row.get("id"); - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let email = Email::new(row.get::("email")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let username = Username::new(row.get::("username")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let hash = PasswordHash::new(row.get::("password_hash")) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - let role_str: String = row.get("role"); - Ok(User::from_persistence( - UserId::from_uuid(id), - email, - username, - hash, - Self::parse_role(&role_str), - domain::models::UserProfile { - display_name: row.try_get("display_name").ok().flatten(), - bio: row.try_get("bio").ok().flatten(), - avatar_path: row.try_get("avatar_path").ok().flatten(), - banner_path: row.try_get("banner_path").ok().flatten(), - also_known_as: row.try_get("also_known_as").ok().flatten(), - profile_fields, - }, - )) - } -} - -const PG_USER_COLS: &str = "id, email, username, password_hash, role, display_name, bio, avatar_path, banner_path, also_known_as"; - -#[async_trait] -impl UserRepository for PostgresUserRepository { - async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - let email_str = email.value(); - let row = sqlx::query(&format!( - "SELECT {PG_USER_COLS} FROM users WHERE email = $1" - )) - .bind(email_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - row.as_ref() - .map(|r| Self::row_to_user(r, vec![])) - .transpose() - } - - async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - let username_str = username.value(); - let row = sqlx::query(&format!( - "SELECT {PG_USER_COLS} FROM users WHERE username = $1" - )) - .bind(username_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - row.as_ref() - .map(|r| Self::row_to_user(r, vec![])) - .transpose() - } - - async fn save(&self, user: &User) -> Result<(), DomainError> { - if self.find_by_email(user.email()).await?.is_some() { - return Err(DomainError::ValidationError( - "Email already registered".into(), - )); - } - if self.find_by_username(user.username()).await?.is_some() { - return Err(DomainError::ValidationError( - "Username already taken".into(), - )); - } - let id = user.id().value().to_string(); - let email = user.email().value(); - let username = user.username().value(); - let hash = user.password_hash().value(); - let created_at = Utc::now() - .naive_utc() - .format("%Y-%m-%d %H:%M:%S") - .to_string(); - let role = match user.role() { - UserRole::Admin => "admin", - UserRole::Standard => "standard", - }; - sqlx::query( - "INSERT INTO users (id, email, username, password_hash, created_at, role) VALUES ($1, $2, $3, $4, $5::timestamptz, $6)", - ) - .bind(&id) - .bind(email) - .bind(username) - .bind(hash) - .bind(&created_at) - .bind(role) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - let id_str = id.value().to_string(); - let row = sqlx::query(&format!("SELECT {PG_USER_COLS} FROM users WHERE id = $1")) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let Some(r) = row else { return Ok(None) }; - - let field_rows = sqlx::query( - "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC", - ) - .bind(&id_str) - .fetch_all(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - let profile_fields = field_rows - .iter() - .map(|f| ProfileField { - name: f.get("name"), - value: f.get("value"), - }) - .collect(); - - Self::row_to_user(&r, profile_fields).map(Some) - } - - async fn update_profile( - &self, - user_id: &UserId, - profile: &domain::models::UserProfile, - ) -> Result<(), DomainError> { - let id_str = user_id.value().to_string(); - sqlx::query( - "UPDATE users SET display_name = $1, bio = $2, avatar_path = $3, banner_path = $4, also_known_as = $5 WHERE id = $6", - ) - .bind(&profile.display_name) - .bind(&profile.bio) - .bind(&profile.avatar_path) - .bind(&profile.banner_path) - .bind(&profile.also_known_as) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - Ok(()) - } - - async fn list_with_stats(&self) -> Result, DomainError> { - sqlx::query_as::<_, UserSummaryRow>( - r#"SELECT u.id, u.email, u.username, u.display_name, - COUNT(DISTINCT r.movie_id) AS total_movies, - AVG(r.rating::float) AS avg_rating, - u.avatar_path - FROM users u - LEFT JOIN reviews r ON r.user_id = u.id AND r.remote_actor_url IS NULL - GROUP BY u.id, u.email, u.username, u.display_name, u.avatar_path - ORDER BY u.email ASC"#, - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)? - .into_iter() - .map(UserSummaryRow::into_domain) - .collect() - } -} diff --git a/crates/adapters/postgres/src/watch_event.rs b/crates/adapters/postgres/src/watch_event.rs deleted file mode 100644 index 310e2747..00000000 --- a/crates/adapters/postgres/src/watch_event.rs +++ /dev/null @@ -1,384 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{PersistedWatchEvent, WatchEvent, WatchEventSource, WatchEventStatus, WebhookToken}, - ports::{WatchEventCommand, WatchEventQuery, WebhookTokenRepository}, - value_objects::{MovieId, UserId, WatchEventId, WebhookTokenId}, -}; -use sqlx::{PgPool, Row}; - -use adapter_common::{parse_datetime, parse_uuid}; - -// ── WatchEventRepository ────────────────────────────────────────────────────── - -pub struct PostgresWatchEventRepository { - pool: PgPool, -} - -impl PostgresWatchEventRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchEventCommand for PostgresWatchEventRepository { - async fn save(&self, event: &WatchEvent) -> Result<(), DomainError> { - let id = event.id().value().to_string(); - let user_id = event.user_id().value().to_string(); - let movie_id = event.movie_id().map(|m| m.value().to_string()); - let source = event.source().to_string(); - let status = event.status().to_string(); - - sqlx::query( - "INSERT INTO watch_events \ - (id, user_id, movie_id, title, year, external_metadata_id, source, watched_at, status, created_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(event.title()) - .bind(event.year().map(|y| y as i32)) - .bind(event.external_metadata_id()) - .bind(&source) - .bind(event.watched_at()) - .bind(&status) - .bind(event.created_at()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WatchEventId, - status: WatchEventStatus, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status.to_string(); - - sqlx::query("UPDATE watch_events SET status = $1 WHERE id = $2") - .bind(&status_str) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status_batch( - &self, - ids: &[WatchEventId], - status: WatchEventStatus, - ) -> Result { - if ids.is_empty() { - return Ok(0); - } - let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let status_str = status.to_string(); - let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)") - .bind(&status_str) - .bind(&id_strs) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } - - async fn delete_non_pending_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let result = - sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1") - .bind(before) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -#[async_trait] -impl WatchEventQuery for PostgresWatchEventRepository { - async fn list_pending(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events \ - WHERE user_id = $1 AND status = 'pending' \ - ORDER BY watched_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_watch_event).collect() - } - - async fn get_by_id(&self, id: &WatchEventId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_watch_event).transpose() - } - - async fn get_by_ids(&self, ids: &[WatchEventId]) -> Result, DomainError> { - if ids.is_empty() { - return Ok(vec![]); - } - let id_strs: Vec = ids.iter().map(|id| id.value().to_string()).collect(); - let rows = sqlx::query( - "SELECT id, user_id, movie_id, title, year, external_metadata_id, \ - source, \ - to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - status, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \ - FROM watch_events WHERE id = ANY($1)", - ) - .bind(&id_strs) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - rows.iter().map(row_to_watch_event).collect() - } - - async fn find_duplicate( - &self, - user_id: &UserId, - external_id: &str, - after: chrono::NaiveDateTime, - ) -> Result { - let uid = user_id.value().to_string(); - - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM watch_events \ - WHERE user_id = $1 AND external_metadata_id = $2 AND created_at > $3", - ) - .bind(&uid) - .bind(external_id) - .bind(after) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(count > 0) - } -} - -fn row_to_watch_event(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let movie_id_str: Option = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; - let year: Option = row - .try_get("year") - .map_err(adapter_common::map_sqlx_error)?; - let ext_id: Option = row - .try_get("external_metadata_id") - .map_err(adapter_common::map_sqlx_error)?; - let source_str: String = row - .try_get("source") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - - let source: WatchEventSource = source_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - let status: WatchEventStatus = status_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let movie_id = movie_id_str - .as_deref() - .map(parse_uuid) - .transpose()? - .map(MovieId::from_uuid); - - Ok(WatchEvent::from_persistence(PersistedWatchEvent { - id: WatchEventId::from_uuid(parse_uuid(&id_str)?), - user_id: UserId::from_uuid(parse_uuid(&user_id_str)?), - movie_id, - title, - year: year.map(|y| y as u16), - external_metadata_id: ext_id, - source, - watched_at: parse_datetime(&watched_at_str)?, - status, - created_at: parse_datetime(&created_at_str)?, - })) -} - -// ── WebhookTokenRepository ──────────────────────────────────────────────────── - -pub struct PostgresWebhookTokenRepository { - pool: PgPool, -} - -impl PostgresWebhookTokenRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WebhookTokenRepository for PostgresWebhookTokenRepository { - async fn save(&self, token: &WebhookToken) -> Result<(), DomainError> { - let id = token.id().value().to_string(); - let user_id = token.user_id().value().to_string(); - let provider = token.provider().to_string(); - - sqlx::query( - "INSERT INTO webhook_tokens \ - (id, user_id, token_hash, provider, label, created_at, last_used_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind(&id) - .bind(&user_id) - .bind(token.token_hash()) - .bind(&provider) - .bind(token.label()) - .bind(token.created_at()) - .bind(token.last_used_at()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn find_by_token_hash(&self, hash: &str) -> Result, DomainError> { - let row = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \ - FROM webhook_tokens WHERE token_hash = $1", - ) - .bind(hash) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_webhook_token).transpose() - } - - async fn list_by_user(&self, user_id: &UserId) -> Result, DomainError> { - let uid = user_id.value().to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, token_hash, provider, label, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(last_used_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS last_used_at \ - FROM webhook_tokens WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_webhook_token).collect() - } - - async fn delete(&self, id: &WebhookTokenId, user_id: &UserId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let uid = user_id.value().to_string(); - - let result = sqlx::query("DELETE FROM webhook_tokens WHERE id = $1 AND user_id = $2") - .bind(&id_str) - .bind(&uid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!("Webhook token {id_str}"))); - } - Ok(()) - } - - async fn touch_last_used(&self, id: &WebhookTokenId) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - - sqlx::query("UPDATE webhook_tokens SET last_used_at = NOW() WHERE id = $1") - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } -} - -fn row_to_webhook_token(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let token_hash: String = row - .try_get("token_hash") - .map_err(adapter_common::map_sqlx_error)?; - let provider_str: String = row - .try_get("provider") - .map_err(adapter_common::map_sqlx_error)?; - let label: Option = row - .try_get("label") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - let last_used_str: Option = row - .try_get("last_used_at") - .map_err(adapter_common::map_sqlx_error)?; - - let provider: WatchEventSource = provider_str - .parse() - .map_err(|e: String| DomainError::InfrastructureError(e))?; - - let last_used = last_used_str.as_deref().map(parse_datetime).transpose()?; - - Ok(WebhookToken::from_persistence( - WebhookTokenId::from_uuid(parse_uuid(&id_str)?), - UserId::from_uuid(parse_uuid(&user_id_str)?), - token_hash, - provider, - label, - parse_datetime(&created_at_str)?, - last_used, - )) -} diff --git a/crates/adapters/postgres/src/watchlist.rs b/crates/adapters/postgres/src/watchlist.rs deleted file mode 100644 index 5b2953f9..00000000 --- a/crates/adapters/postgres/src/watchlist.rs +++ /dev/null @@ -1,189 +0,0 @@ -use async_trait::async_trait; -use domain::{ - errors::DomainError, - models::{ - WatchlistEntry, WatchlistWithMovie, - collections::{PageParams, Paginated}, - }, - ports::WatchlistRepository, - value_objects::{MovieId, UserId, WatchlistEntryId}, -}; -use sqlx::{PgPool, Row}; - -use crate::models::MovieRow; -use adapter_common::{parse_datetime, parse_uuid}; - -pub struct PostgresWatchlistRepository { - pool: PgPool, -} - -impl PostgresWatchlistRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WatchlistRepository for PostgresWatchlistRepository { - async fn add(&self, entry: &WatchlistEntry) -> Result<(), DomainError> { - let id = entry.id.value().to_string(); - let user_id = entry.user_id.value().to_string(); - let movie_id = entry.movie_id.value().to_string(); - let added_at = entry.added_at; - - sqlx::query( - "INSERT INTO watchlist_entries (id, user_id, movie_id, added_at) \ - VALUES ($1, $2, $3, $4) \ - ON CONFLICT (user_id, movie_id) DO NOTHING", - ) - .bind(&id) - .bind(&user_id) - .bind(&movie_id) - .bind(added_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn remove(&self, user_id: &UserId, movie_id: &MovieId) -> Result<(), DomainError> { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - - let result = - sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if result.rows_affected() == 0 { - return Err(DomainError::NotFound(format!( - "Watchlist entry for movie {} not found", - mid - ))); - } - Ok(()) - } - - async fn remove_if_present( - &self, - user_id: &UserId, - movie_id: &MovieId, - ) -> Result { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - let result = - sqlx::query("DELETE FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2") - .bind(&uid) - .bind(&mid) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected() > 0) - } - - async fn get_for_user( - &self, - user_id: &UserId, - page: &PageParams, - ) -> Result, DomainError> { - let uid = user_id.value().to_string(); - let limit = page.limit as i64; - let offset = page.offset as i64; - - let rows = sqlx::query( - "SELECT w.id, w.user_id, w.movie_id, \ - to_char(w.added_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS added_at, \ - m.id AS m_id, m.external_metadata_id, m.title, m.release_year, \ - m.director, m.poster_path \ - FROM watchlist_entries w \ - JOIN movies m ON m.id = w.movie_id \ - WHERE w.user_id = $1 \ - ORDER BY w.added_at DESC \ - LIMIT $2 OFFSET $3", - ) - .bind(&uid) - .bind(limit) - .bind(offset) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let total: i64 = - sqlx::query_scalar("SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1") - .bind(&uid) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let items = rows - .into_iter() - .map(|row| { - let entry = WatchlistEntry { - id: WatchlistEntryId::from_uuid(parse_uuid( - &row.try_get::("id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - user_id: UserId::from_uuid(parse_uuid( - &row.try_get::("user_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - movie_id: MovieId::from_uuid(parse_uuid( - &row.try_get::("movie_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?), - added_at: parse_datetime( - &row.try_get::("added_at") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - )?, - }; - let movie = MovieRow { - id: row - .try_get("m_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - external_metadata_id: row - .try_get("external_metadata_id") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - title: row - .try_get("title") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - release_year: row - .try_get("release_year") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - director: row - .try_get("director") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - poster_path: row - .try_get("poster_path") - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, - } - .into_domain()?; - Ok(WatchlistWithMovie { entry, movie }) - }) - .collect::, DomainError>>()?; - - Ok(Paginated { - items, - total_count: total as u64, - limit: page.limit, - offset: page.offset, - }) - } - - async fn contains(&self, user_id: &UserId, movie_id: &MovieId) -> Result { - let uid = user_id.value().to_string(); - let mid = movie_id.value().to_string(); - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM watchlist_entries WHERE user_id = $1 AND movie_id = $2", - ) - .bind(&uid) - .bind(&mid) - .fetch_one(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(count > 0) - } -} diff --git a/crates/adapters/postgres/src/wrapup.rs b/crates/adapters/postgres/src/wrapup.rs deleted file mode 100644 index ffffda6b..00000000 --- a/crates/adapters/postgres/src/wrapup.rs +++ /dev/null @@ -1,508 +0,0 @@ -use std::collections::HashMap; - -use async_trait::async_trait; -use chrono::NaiveDate; -use domain::{ - errors::DomainError, - models::wrapup::{ - DateRange, WrapUpMovieRow, WrapUpRecord, WrapUpReport, WrapUpScope, WrapUpStatus, - }, - ports::{WrapUpRepository, WrapUpStatsQuery}, - value_objects::WrapUpId, -}; -use sqlx::{PgPool, Row}; -use uuid::Uuid; - -use adapter_common::{parse_datetime, parse_uuid}; - -fn status_to_str(s: &WrapUpStatus) -> &'static str { - match s { - WrapUpStatus::Pending => "pending", - WrapUpStatus::Generating => "generating", - WrapUpStatus::Ready => "ready", - WrapUpStatus::Failed => "failed", - } -} - -fn parse_status(s: &str) -> Result { - match s { - "pending" => Ok(WrapUpStatus::Pending), - "generating" => Ok(WrapUpStatus::Generating), - "ready" => Ok(WrapUpStatus::Ready), - "failed" => Ok(WrapUpStatus::Failed), - other => Err(DomainError::InfrastructureError(format!( - "Unknown wrap-up status: {other}" - ))), - } -} - -// ── WrapUpRepository ───────────────────────────────────────────────────────── - -pub struct PostgresWrapUpRepository { - pool: PgPool, -} - -impl PostgresWrapUpRepository { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpRepository for PostgresWrapUpRepository { - async fn create(&self, record: &WrapUpRecord) -> Result<(), DomainError> { - let id = record.id.value().to_string(); - let user_id = record.user_id.map(|u| u.to_string()); - let status = status_to_str(&record.status); - - sqlx::query( - "INSERT INTO wrap_up_records \ - (id, user_id, start_date, end_date, status, report_json, error_message, created_at, completed_at) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", - ) - .bind(&id) - .bind(&user_id) - .bind(record.start_date) - .bind(record.end_date) - .bind(status) - .bind(record.report.as_ref().and_then(|r| serde_json::to_string(r).ok())) - .bind(&record.error_message) - .bind(record.created_at) - .bind(record.completed_at) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn update_status( - &self, - id: &WrapUpId, - status: &WrapUpStatus, - error: Option<&str>, - ) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let status_str = status_to_str(status); - - sqlx::query("UPDATE wrap_up_records SET status = $1, error_message = $2 WHERE id = $3") - .bind(status_str) - .bind(error) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn set_complete(&self, id: &WrapUpId, report: &WrapUpReport) -> Result<(), DomainError> { - let id_str = id.value().to_string(); - let json = serde_json::to_string(report) - .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - sqlx::query( - "UPDATE wrap_up_records \ - SET status = 'ready', report_json = $1, completed_at = NOW() \ - WHERE id = $2", - ) - .bind(&json) - .bind(&id_str) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - Ok(()) - } - - async fn get_by_id(&self, id: &WrapUpId) -> Result, DomainError> { - let id_str = id.value().to_string(); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE id = $1", - ) - .bind(&id_str) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn list_for_user(&self, user_id: Uuid) -> Result, DomainError> { - let uid = user_id.to_string(); - - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE user_id = $1 ORDER BY created_at DESC", - ) - .bind(&uid) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn list_global(&self) -> Result, DomainError> { - let rows = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records WHERE user_id IS NULL ORDER BY created_at DESC", - ) - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - rows.iter().map(row_to_record).collect() - } - - async fn find_existing( - &self, - user_id: Option, - start: NaiveDate, - end: NaiveDate, - ) -> Result, DomainError> { - let uid = user_id.map(|u| u.to_string()); - - let row = sqlx::query( - "SELECT id, user_id, start_date, end_date, status, report_json, error_message, \ - to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at, \ - to_char(completed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS completed_at \ - FROM wrap_up_records \ - WHERE (($1::text IS NULL AND user_id IS NULL) OR user_id = $1) \ - AND start_date = $2 AND end_date = $3 \ - LIMIT 1", - ) - .bind(&uid) - .bind(start) - .bind(end) - .fetch_optional(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - row.as_ref().map(row_to_record).transpose() - } - - async fn delete(&self, id: &WrapUpId) -> Result<(), DomainError> { - sqlx::query("DELETE FROM wrap_up_records WHERE id = $1") - .bind(id.value().to_string()) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(()) - } - - async fn delete_failed_older_than( - &self, - before: chrono::NaiveDateTime, - ) -> Result { - let result = - sqlx::query("DELETE FROM wrap_up_records WHERE status = 'failed' AND created_at < $1") - .bind(before) - .execute(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - Ok(result.rows_affected()) - } -} - -fn row_to_record(row: &sqlx::postgres::PgRow) -> Result { - let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?; - let user_id_str: Option = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let start_date: NaiveDate = row - .try_get("start_date") - .map_err(adapter_common::map_sqlx_error)?; - let end_date: NaiveDate = row - .try_get("end_date") - .map_err(adapter_common::map_sqlx_error)?; - let status_str: String = row - .try_get("status") - .map_err(adapter_common::map_sqlx_error)?; - let report_json: Option = row - .try_get("report_json") - .map_err(adapter_common::map_sqlx_error)?; - let error_message: Option = row - .try_get("error_message") - .map_err(adapter_common::map_sqlx_error)?; - let created_at_str: String = row - .try_get("created_at") - .map_err(adapter_common::map_sqlx_error)?; - let completed_at_str: Option = row - .try_get("completed_at") - .map_err(adapter_common::map_sqlx_error)?; - - let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?; - - let report = report_json.and_then(|j| serde_json::from_str(&j).ok()); - - Ok(WrapUpRecord { - id: WrapUpId::from_uuid(parse_uuid(&id_str)?), - user_id, - start_date, - end_date, - status: parse_status(&status_str)?, - report, - error_message, - created_at: parse_datetime(&created_at_str)?, - completed_at: completed_at_str - .as_deref() - .map(parse_datetime) - .transpose()?, - }) -} - -// ── WrapUpStatsQuery ───────────────────────────────────────────────────────── - -pub struct PostgresWrapUpStatsQuery { - pool: PgPool, -} - -impl PostgresWrapUpStatsQuery { - pub fn new(pool: PgPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl WrapUpStatsQuery for PostgresWrapUpStatsQuery { - async fn get_reviews_with_profiles( - &self, - scope: &WrapUpScope, - range: &DateRange, - ) -> Result, DomainError> { - // 1) Main query: reviews + movies + movie_profiles - let (scope_clause, scope_bind) = match scope { - WrapUpScope::User(uid) => ("AND r.user_id = $3", Some(uid.to_string())), - WrapUpScope::Global => ("", None), - }; - - let sql = format!( - "SELECT r.movie_id, m.title, m.release_year, m.director, m.poster_path, \ - r.rating, \ - to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at, \ - r.user_id, r.watch_medium, \ - p.runtime_minutes, p.budget_usd, p.original_language \ - FROM reviews r \ - INNER JOIN movies m ON m.id = r.movie_id \ - LEFT JOIN movie_profiles p ON p.movie_id = m.id \ - WHERE r.watched_at >= $1 AND r.watched_at < $2 {scope_clause} \ - ORDER BY r.watched_at ASC" - ); - - let mut q = sqlx::query(&sql).bind(range.start()).bind(range.end()); - if let Some(ref uid) = scope_bind { - q = q.bind(uid); - } - - let rows = q - .fetch_all(&self.pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - if rows.is_empty() { - return Ok(vec![]); - } - - // Collect unique movie IDs - let mut movie_ids: Vec = Vec::new(); - let mut seen = std::collections::HashSet::new(); - for row in &rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - if seen.insert(mid.clone()) { - movie_ids.push(mid); - } - } - - // 2) Batch-fetch genres, keywords, cast - let (genres_map, keywords_map, cast_map) = tokio::try_join!( - fetch_genres_pg(&self.pool, &movie_ids), - fetch_keywords_pg(&self.pool, &movie_ids), - fetch_cast_pg(&self.pool, &movie_ids), - )?; - - // 3) Build result - let mut result = Vec::with_capacity(rows.len()); - for row in &rows { - let movie_id_str: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let title: String = row - .try_get("title") - .map_err(adapter_common::map_sqlx_error)?; - let release_year: i64 = row - .try_get("release_year") - .map_err(adapter_common::map_sqlx_error)?; - let director: Option = row - .try_get("director") - .map_err(adapter_common::map_sqlx_error)?; - let poster_path: Option = row - .try_get("poster_path") - .map_err(adapter_common::map_sqlx_error)?; - let rating: i64 = row - .try_get("rating") - .map_err(adapter_common::map_sqlx_error)?; - let watched_at_str: String = row - .try_get("watched_at") - .map_err(adapter_common::map_sqlx_error)?; - let user_id_str: String = row - .try_get("user_id") - .map_err(adapter_common::map_sqlx_error)?; - let runtime_minutes: Option = row - .try_get("runtime_minutes") - .map_err(adapter_common::map_sqlx_error)?; - let budget_usd: Option = row - .try_get("budget_usd") - .map_err(adapter_common::map_sqlx_error)?; - let original_language: Option = row - .try_get("original_language") - .map_err(adapter_common::map_sqlx_error)?; - let watch_medium: Option = row - .try_get("watch_medium") - .map_err(adapter_common::map_sqlx_error)?; - - let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default(); - let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default(); - let cast = cast_map.get(&movie_id_str).cloned().unwrap_or_default(); - - let cast_names: Vec<(String, u32, i64)> = cast - .iter() - .map(|c| (c.name.clone(), c.billing_order, c.tmdb_person_id)) - .collect(); - let cast_profile_paths: Vec> = - cast.iter().map(|c| c.profile_path.clone()).collect(); - - result.push(WrapUpMovieRow { - movie_id: parse_uuid(&movie_id_str)?, - title, - release_year: release_year as u16, - director, - poster_path, - rating: rating as u8, - watched_at: parse_datetime(&watched_at_str)?, - user_id: parse_uuid(&user_id_str)?, - runtime_minutes: runtime_minutes.map(|v| v as u32), - budget_usd, - original_language, - watch_medium, - genres, - keywords, - cast_names, - cast_profile_paths, - }); - } - - Ok(result) - } -} - -#[derive(Clone)] -struct CastEntry { - name: String, - billing_order: u32, - tmdb_person_id: i64, - profile_path: Option, -} - -/// Shared by WrapUp and Insights: movie-id → genre names. -pub(crate) async fn fetch_genres_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name FROM movie_genres WHERE movie_id = ANY($1) ORDER BY name", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(name); - } - Ok(map) -} - -async fn fetch_keywords_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name FROM movie_keywords WHERE movie_id = ANY($1) ORDER BY name", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(name); - } - Ok(map) -} - -async fn fetch_cast_pg( - pool: &PgPool, - movie_ids: &[String], -) -> Result>, DomainError> { - let rows = sqlx::query( - "SELECT movie_id, name, billing_order, tmdb_person_id, profile_path \ - FROM movie_cast \ - WHERE movie_id = ANY($1) AND billing_order <= 3 \ - ORDER BY billing_order ASC", - ) - .bind(movie_ids) - .fetch_all(pool) - .await - .map_err(adapter_common::map_sqlx_error)?; - - let mut map: HashMap> = HashMap::new(); - for row in rows { - let mid: String = row - .try_get("movie_id") - .map_err(adapter_common::map_sqlx_error)?; - let name: String = row - .try_get("name") - .map_err(adapter_common::map_sqlx_error)?; - let billing_order: i32 = row - .try_get("billing_order") - .map_err(adapter_common::map_sqlx_error)?; - let tmdb_person_id: i64 = row - .try_get("tmdb_person_id") - .map_err(adapter_common::map_sqlx_error)?; - let profile_path: Option = row - .try_get("profile_path") - .map_err(adapter_common::map_sqlx_error)?; - map.entry(mid).or_default().push(CastEntry { - name, - billing_order: billing_order as u32, - tmdb_person_id, - profile_path, - }); - } - Ok(map) -} diff --git a/crates/composition/Cargo.toml b/crates/composition/Cargo.toml index a9ad821d..de444330 100644 --- a/crates/composition/Cargo.toml +++ b/crates/composition/Cargo.toml @@ -6,11 +6,9 @@ edition.workspace = true [features] default = [] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "dep:sqlite-social", "infra-wiring/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "dep:postgres-social", "infra-wiring/postgres"] nats = ["dep:nats", "infra-wiring/nats"] federation = ["application/federation"] sqlite-federation = ["sqlite", "dep:sqlite-federation", "dep:activitypub", "federation"] -postgres-federation = ["postgres", "dep:postgres-federation", "dep:activitypub", "federation"] [dependencies] domain = { workspace = true } @@ -25,17 +23,12 @@ plex = { workspace = true } anyhow = { workspace = true } sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } sqlite-social = { workspace = true, optional = true } -postgres-social = { workspace = true, optional = true } nats = { workspace = true, optional = true } activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } [dev-dependencies] async-trait = { workspace = true } diff --git a/crates/composition/src/factory.rs b/crates/composition/src/factory.rs index 6e36146e..efe5fa16 100644 --- a/crates/composition/src/factory.rs +++ b/crates/composition/src/factory.rs @@ -48,126 +48,59 @@ pub struct DatabaseOutput { } pub async fn build_database_adapters( - backend: &str, url: &str, instance: &domain::value_objects::InstanceIdentity, ) -> anyhow::Result { - match backend { - #[cfg(feature = "postgres")] - "postgres" => { - let w = postgres::wire(url) - .await - .context("PostgreSQL connection failed")?; - let (image_ref_command, image_ref_query) = postgres::create_image_ref(w.pool.clone()); - let (pc, pq) = postgres::create_person_adapter(w.pool.clone()); - let (sc, sp) = postgres_search::create_search_adapter(w.pool.clone()); - let pf = postgres::create_profile_fields_repo(w.pool.clone()); - let we = Arc::new(postgres::PostgresWatchEventRepository::new(w.pool.clone())); - let wt: Arc = Arc::new( - postgres::PostgresWebhookTokenRepository::new(w.pool.clone()), - ); - let social = Arc::new(postgres_social::PostgresSocialRepository::new( - w.pool.clone(), - instance.clone(), - )); - Ok(DatabaseOutput { - movie_command: w.movie_command, - movie_query: w.movie_query, - review: w.review, - diary: w.diary, - stats: w.stats, - user: w.user, - import_session: w.import_session, - import_profile: w.import_profile, - movie_profile: w.movie_profile, - watchlist: w.watchlist, - watch_event_command: we.clone() as _, - watch_event_query: we as _, - webhook_token: wt, - person_command: pc, - person_query: pq, - search_port: sp, - search_command: sc, - profile_fields: pf, - ap_content: w.ap_content, - wrapup_stats: w.wrapup_stats, - wrapup_repo: w.wrapup_repo, - insights: w.insights, - goal_command: w.goal_command, - goal_query: w.goal_query, - user_settings: w.user_settings, - federation_settings: w.federation_settings, - remote_goal: w.remote_goal, - refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( - w.pool.clone(), - )) as _, - deduplicator: w.deduplicator, - image_ref_command, - image_ref_query, - follow_command: Arc::clone(&social) as _, - follow_query: social as _, - db_pool: DbPool::Postgres(w.pool), - }) - } - #[cfg(feature = "sqlite")] - _ => { - let w = sqlite::wire(url) - .await - .context("SQLite connection failed")?; - let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone()); - let (pc, pq) = sqlite::create_person_adapter(w.pool.clone()); - let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone()); - let pf = sqlite::create_profile_fields_repo(w.pool.clone()); - let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); - let wt: Arc = - Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); - let social = Arc::new(sqlite_social::SqliteSocialRepository::new( - w.pool.clone(), - instance.clone(), - )); - Ok(DatabaseOutput { - movie_command: w.movie_command, - movie_query: w.movie_query, - review: w.review, - diary: w.diary, - stats: w.stats, - user: w.user, - import_session: w.import_session, - import_profile: w.import_profile, - movie_profile: w.movie_profile, - watchlist: w.watchlist, - watch_event_command: we.clone() as _, - watch_event_query: we as _, - webhook_token: wt, - person_command: pc, - person_query: pq, - search_port: sp, - search_command: sc, - profile_fields: pf, - ap_content: w.ap_content, - wrapup_stats: w.wrapup_stats, - wrapup_repo: w.wrapup_repo, - insights: w.insights, - goal_command: w.goal_command, - goal_query: w.goal_query, - user_settings: w.user_settings, - federation_settings: w.federation_settings, - remote_goal: w.remote_goal, - refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) - as _, - deduplicator: w.deduplicator, - image_ref_command, - image_ref_query, - follow_command: Arc::clone(&social) as _, - follow_query: social as _, - db_pool: DbPool::Sqlite(w.pool), - }) - } - #[cfg(not(feature = "sqlite"))] - _ => anyhow::bail!( - "DATABASE_BACKEND={backend} is not supported by this build (enable sqlite or postgres feature)" - ), - } + let w = sqlite::wire(url) + .await + .context("SQLite connection failed")?; + let (image_ref_command, image_ref_query) = sqlite::create_image_ref(w.pool.clone()); + let (pc, pq) = sqlite::create_person_adapter(w.pool.clone()); + let (sc, sp) = sqlite_search::create_search_adapter(w.pool.clone()); + let pf = sqlite::create_profile_fields_repo(w.pool.clone()); + let we = Arc::new(sqlite::SqliteWatchEventRepository::new(w.pool.clone())); + let wt: Arc = + Arc::new(sqlite::SqliteWebhookTokenRepository::new(w.pool.clone())); + let social = Arc::new(sqlite_social::SqliteSocialRepository::new( + w.pool.clone(), + instance.clone(), + )); + Ok(DatabaseOutput { + movie_command: w.movie_command, + movie_query: w.movie_query, + review: w.review, + diary: w.diary, + stats: w.stats, + user: w.user, + import_session: w.import_session, + import_profile: w.import_profile, + movie_profile: w.movie_profile, + watchlist: w.watchlist, + watch_event_command: we.clone() as _, + watch_event_query: we as _, + webhook_token: wt, + person_command: pc, + person_query: pq, + search_port: sp, + search_command: sc, + profile_fields: pf, + ap_content: w.ap_content, + wrapup_stats: w.wrapup_stats, + wrapup_repo: w.wrapup_repo, + insights: w.insights, + goal_command: w.goal_command, + goal_query: w.goal_query, + user_settings: w.user_settings, + federation_settings: w.federation_settings, + remote_goal: w.remote_goal, + refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _, + deduplicator: w.deduplicator, + image_ref_command, + image_ref_query, + follow_command: Arc::clone(&social) as _, + follow_query: social as _, + db_pool: DbPool::Sqlite(w.pool), + }) } pub fn build_auth_adapters() -> anyhow::Result<(Arc, Arc)> { @@ -190,8 +123,6 @@ pub fn build_profile_fields_repo( pool: &DbPool, ) -> anyhow::Result> { match pool { - #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => Ok(postgres::create_profile_fields_repo(pool.clone())), #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => Ok(sqlite::create_profile_fields_repo(pool.clone())), #[cfg(not(feature = "sqlite"))] diff --git a/crates/infra-wiring/Cargo.toml b/crates/infra-wiring/Cargo.toml index fff101eb..fcacb81f 100644 --- a/crates/infra-wiring/Cargo.toml +++ b/crates/infra-wiring/Cargo.toml @@ -10,5 +10,4 @@ anyhow = { workspace = true } [features] default = [] sqlite = [] -postgres = ["sqlx/postgres"] nats = [] diff --git a/crates/infra-wiring/src/lib.rs b/crates/infra-wiring/src/lib.rs index dae26a9e..bb9dc566 100644 --- a/crates/infra-wiring/src/lib.rs +++ b/crates/infra-wiring/src/lib.rs @@ -5,8 +5,6 @@ pub use config::{AppConfig, WrapUpConfig}; pub enum DbPool { #[cfg(feature = "sqlite")] Sqlite(sqlx::SqlitePool), - #[cfg(feature = "postgres")] - Postgres(sqlx::PgPool), } #[derive(Clone, Copy)] diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 4f5fdacf..bc333407 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -8,7 +8,6 @@ license = "MIT" [features] default = ["sqlite", "sqlite-federation"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres", "composition/postgres"] nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] # Meta-feature: true when any federation adapter is active — keeps all #[cfg(feature = "federation")] gates working federation = ["application/federation", "presentation/federation"] @@ -20,14 +19,6 @@ sqlite-federation = [ "federation", "composition/sqlite-federation", ] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:postgres-social", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] [dependencies] axum = { workspace = true } @@ -55,18 +46,13 @@ async-trait = { workspace = true } # Optional — database backends sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } # Optional — federation activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } sqlite-social = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } -postgres-social = { workspace = true, optional = true } [dev-dependencies] bytes = { workspace = true } diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 5d73a0f7..8d14a952 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -16,13 +16,8 @@ use rss::RssAdapter; use domain::ports::{DiaryExporter, DocumentParser, EventPublisher}; use infra_wiring::EventBusBackend; -#[cfg(feature = "postgres")] -use postgres_search; - -#[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!( - "At least one database backend must be enabled. Use --features sqlite or --features postgres" -); +#[cfg(not(feature = "sqlite"))] +compile_error!("The sqlite feature must be enabled. Use --features sqlite"); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -53,15 +48,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); + warn_if_database_backend_set(); let (auth_service, password_hasher) = composition::factory::build_auth_adapters()?; let metadata_client = composition::factory::build_metadata_client()?; let poster_fetcher = composition::factory::build_poster_fetcher()?; let object_storage = composition::factory::build_object_storage()?; - let db = - composition::factory::build_database_adapters(&backend, &database_url, &instance).await?; + let db = composition::factory::build_database_adapters(&database_url, &instance).await?; + #[cfg(feature = "federation")] let ap_content_repo = db.ap_content; let db_pool = db.db_pool; @@ -81,18 +76,10 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { block_query_arc, ) = { let fed_repos = match &db_pool { - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_federation::wire(pool.clone(), instance.clone()) - } #[cfg(feature = "sqlite-federation")] composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } - #[cfg(not(feature = "sqlite-federation"))] - _ => anyhow::bail!( - "DATABASE_BACKEND={backend} federation is not supported by this build" - ), }; let ep = create_event_publisher(event_bus, &db_pool).await?; @@ -231,12 +218,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { composition::DbPool::Sqlite(pool) => { sqlite_social::create_federated_profile_query(pool.clone(), instance.clone()) } - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_social::create_federated_profile_query(pool.clone(), instance.clone()) - } - #[cfg(not(feature = "sqlite-federation"))] - _ => unreachable!(), } }), #[cfg(not(feature = "federation"))] @@ -291,10 +272,6 @@ async fn create_event_publisher( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); Ok(match db_pool { - #[cfg(feature = "postgres")] - composition::DbPool::Postgres(pool) => { - postgres_event_queue::PostgresEventQueue::create_publisher(pool.clone()).await? - } #[cfg(feature = "sqlite")] composition::DbPool::Sqlite(pool) => { sqlite_event_queue::SqliteEventQueue::create_publisher(pool.clone()).await? @@ -311,6 +288,14 @@ async fn create_event_publisher( } } +fn warn_if_database_backend_set() { + if let Ok(v) = std::env::var("DATABASE_BACKEND") + && v != "sqlite" + { + tracing::warn!("DATABASE_BACKEND is ignored; SQLite is the only backend"); + } +} + fn init_tracing() { tracing_subscriber::registry() .with(log_filter("server=debug,tower_http=debug")) diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index ce02a2d5..d81add48 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -6,7 +6,6 @@ edition.workspace = true [features] default = ["sqlite", "sqlite-federation"] sqlite = ["dep:sqlite", "dep:sqlite-event-queue", "dep:sqlite-search", "infra-wiring/sqlite", "composition/sqlite"] -postgres = ["dep:postgres", "dep:postgres-event-queue", "dep:postgres-search", "infra-wiring/postgres", "composition/postgres"] nats = ["dep:nats", "infra-wiring/nats", "composition/nats"] federation = ["application/federation"] sqlite-federation = [ @@ -16,13 +15,6 @@ sqlite-federation = [ "federation", "composition/sqlite-federation", ] -postgres-federation = [ - "postgres", - "dep:postgres-federation", - "dep:activitypub", - "federation", - "composition/postgres-federation", -] [dependencies] domain = { workspace = true } @@ -49,13 +41,9 @@ async-trait = { workspace = true } # Optional — database backends sqlite = { workspace = true, optional = true } -postgres = { workspace = true, optional = true } sqlite-event-queue = { workspace = true, optional = true } -postgres-event-queue = { workspace = true, optional = true } sqlite-search = { workspace = true, optional = true } -postgres-search = { workspace = true, optional = true } # Optional — federation activitypub = { workspace = true, optional = true } sqlite-federation = { workspace = true, optional = true } -postgres-federation = { workspace = true, optional = true } diff --git a/crates/worker/src/event_bus.rs b/crates/worker/src/event_bus.rs index cc0c49ae..24bfd543 100644 --- a/crates/worker/src/event_bus.rs +++ b/crates/worker/src/event_bus.rs @@ -13,10 +13,6 @@ pub async fn create( EventBusBackend::Db => { tracing::info!("event bus: DB queue"); match db_pool { - #[cfg(feature = "postgres")] - DbPool::Postgres(pool) => Ok( - postgres_event_queue::PostgresEventQueue::create_channel(pool.clone()).await?, - ), #[cfg(feature = "sqlite")] DbPool::Sqlite(pool) => { Ok(sqlite_event_queue::SqliteEventQueue::create_channel(pool.clone()).await?) diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 867b309f..f2505713 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,4 +1,5 @@ mod event_bus; +#[cfg(feature = "federation")] mod follow_backfill_handler; use std::sync::Arc; @@ -12,10 +13,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use domain::ports::{EventHandler, MovieEnrichmentClient, PeriodicJob, PersonEnrichmentClient}; -#[cfg(not(any(feature = "sqlite", feature = "postgres")))] -compile_error!( - "At least one database backend must be enabled. Use --features sqlite or --features postgres" -); +#[cfg(not(feature = "sqlite"))] +compile_error!("The sqlite feature must be enabled. Use --features sqlite"); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -23,7 +22,7 @@ async fn main() -> anyhow::Result<()> { init_tracing(); let database_url = std::env::var("DATABASE_URL").context("DATABASE_URL must be set")?; - let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "sqlite".to_string()); + warn_if_database_backend_set(); let app_config = AppConfig::from_env(); let instance = domain::value_objects::InstanceIdentity::new(app_config.base_url.clone()); @@ -47,8 +46,7 @@ async fn main() -> anyhow::Result<()> { .clone() .map(|c| c as Arc); - let db = - composition::factory::build_database_adapters(&backend, &database_url, &instance).await?; + let db = composition::factory::build_database_adapters(&database_url, &instance).await?; let (event_publisher_arc, consumer_arc) = event_bus::create(&db.db_pool).await?; let worker_services = application::WorkerServices { @@ -88,10 +86,6 @@ async fn main() -> anyhow::Result<()> { composition::DbPool::Sqlite(pool) => { sqlite_federation::wire(pool.clone(), instance.clone()) } - #[cfg(feature = "postgres-federation")] - composition::DbPool::Postgres(pool) => { - postgres_federation::wire(pool.clone(), instance.clone()) - } }; let movie_command = db.movie_command; @@ -103,6 +97,7 @@ async fn main() -> anyhow::Result<()> { let person_query = db.person_query; let search_command = db.search_command; let wrapup_repo = db.wrapup_repo; + #[cfg(feature = "federation")] let remote_goal = db.remote_goal; let refresh_session = db.refresh_session; @@ -306,6 +301,14 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +fn warn_if_database_backend_set() { + if let Ok(v) = std::env::var("DATABASE_BACKEND") + && v != "sqlite" + { + tracing::warn!("DATABASE_BACKEND is ignored; SQLite is the only backend"); + } +} + fn init_tracing() { tracing_subscriber::registry() .with(log_filter("worker=info,application=info,k_ap=info"))