refactor/tighten-architecture #26

Merged
GKaszewski merged 104 commits from refactor/tighten-architecture into master 2026-10-26 09:36:27 +00:00
82 changed files with 77 additions and 7937 deletions
Showing only changes of commit 933400e1c2 - Show all commits

91
Cargo.lock generated
View File

@@ -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",

View File

@@ -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" }

View File

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

View File

@@ -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);

View File

@@ -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<DbEventQueueConfig>,
}
impl PostgresEventQueue {
pub async fn create(pool: PgPool, config: DbEventQueueConfig) -> anyhow::Result<Self> {
migrations::run(&pool).await?;
Ok(Self {
pool,
config: Arc::new(config),
})
}
pub async fn create_publisher(pool: PgPool) -> anyhow::Result<Arc<dyn EventPublisher>> {
let q = Self::create(pool, DbEventQueueConfig::from_env()).await?;
Ok(Arc::new(q))
}
pub async fn create_channel(
pool: PgPool,
) -> anyhow::Result<(Arc<dyn EventPublisher>, Arc<dyn EventConsumer>)> {
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<EventEnvelope, DomainError>> {
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<Vec<QueueRow>, 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<EventEnvelope, DomainError> {
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)
}

View File

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

View File

@@ -1 +0,0 @@
pub use event_payload::EventPayload as DbEventPayload;

View File

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

View File

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

View File

@@ -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<Option<Keypair>> {
let uid = user_id.to_string();
let row =
sqlx::query("SELECT public_key, private_key FROM ap_local_actors WHERE user_id = $1")
.bind(&uid)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| 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<Option<RemoteActor>> {
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<chrono::Utc>,
) -> 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<usize> {
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::<i64, _>("cnt") as usize)
}
}

View File

@@ -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<Vec<BlockedDomain>> {
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<bool> {
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<Vec<String>> {
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::<String, _>("remote_actor_url"))
.collect())
}
async fn is_actor_blocked(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<bool> {
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)
}
}

View File

@@ -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<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = 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<Vec<Follower>> {
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<Vec<Follower>> {
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<usize> {
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<Vec<RemoteActor>> {
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<Vec<String>> {
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::<String, _>("inbox").ok())
.collect())
}
async fn count_accepted_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_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<Vec<RemoteActor>> {
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())
}
}

View File

@@ -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<Option<String>> {
let uid = local_user_id.to_string();
let row: Option<String> = sqlx::query_scalar("SELECT follow_activity_id FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid).bind(remote_actor_url).fetch_optional(&self.pool).await?;
Ok(row)
}
async fn remove_following(&self, local_user_id: uuid::Uuid, actor_url: &str) -> Result<()> {
let uid = local_user_id.to_string();
sqlx::query("DELETE FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2")
.bind(&uid)
.bind(actor_url)
.execute(&self.pool)
.await?;
Ok(())
}
async fn update_following_status(
&self,
local_user_id: uuid::Uuid,
remote_actor_url: &str,
status: FollowingStatus,
) -> Result<()> {
let uid = local_user_id.to_string();
let status_str = match status {
FollowingStatus::Pending => "pending",
FollowingStatus::Accepted => "accepted",
};
let result = sqlx::query("UPDATE ap_following SET status = $1 WHERE local_user_id = $2 AND remote_actor_url = $3")
.bind(status_str).bind(&uid).bind(remote_actor_url).execute(&self.pool).await?;
if result.rows_affected() == 0 {
tracing::warn!(local_user_id = %local_user_id, remote_actor_url, "update_following_status: no row found");
}
Ok(())
}
}
#[async_trait]
impl FollowingReader for PostgresFederationRepository {
async fn get_following(&self, local_user_id: uuid::Uuid) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted'"
);
let rows = sqlx::query(&q).bind(&uid).fetch_all(&self.pool).await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
async fn count_following(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let q = format!(
"SELECT a.url, {PG_ACTOR_COLS} FROM ap_following f INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url WHERE f.local_user_id = $1 AND f.status = 'accepted' ORDER BY f.created_at ASC LIMIT $2 OFFSET $3"
);
let rows = sqlx::query(&q)
.bind(&uid)
.bind(limit as i64)
.bind(offset as i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.iter().map(|row| pg_remote_actor(row, "url")).collect())
}
}

View File

@@ -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<Vec<uuid::Uuid>> {
let candidates: Vec<String> = sqlx::query_scalar(
"SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $2)",
).bind(old_actor_url).bind(new_actor_url).fetch_all(&self.pool).await?;
if candidates.is_empty() {
return Ok(vec![]);
}
sqlx::query("UPDATE ap_following SET remote_actor_url = $1 WHERE remote_actor_url = $2 AND local_user_id NOT IN (SELECT local_user_id FROM ap_following WHERE remote_actor_url = $1)")
.bind(new_actor_url).bind(old_actor_url).execute(&self.pool).await?;
candidates
.into_iter()
.map(|s| uuid::Uuid::parse_str(&s).map_err(|e| anyhow::anyhow!(e)))
.collect()
}
}

View File

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

View File

@@ -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::<Option<String>, _>("also_known_as")
.ok()
.flatten()
.map(|s| {
serde_json::from_str::<Vec<String>>(&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 _,
}
}

View File

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

View File

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

View File

@@ -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<dyn SearchCommand>, Arc<dyn SearchPort>) {
let adapter = Arc::new(PostgresSearchAdapter::new(pool));
(
Arc::clone(&adapter) as Arc<dyn SearchCommand>,
adapter as Arc<dyn SearchPort>,
)
}
#[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::<Vec<_>>()
.join(" "),
p.keywords
.iter()
.map(|k| k.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.cast
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.join(" "),
p.crew
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>()
.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<SearchResults, DomainError> {
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<Paginated<MovieSearchHit>, 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<i32>,
director: Option<String>,
poster_path: Option<String>,
genres: Option<String>,
}
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<Row> = 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::<Vec<_>>();
Ok(Paginated {
items,
total_count: total,
limit: query.page.limit,
offset: query.page.offset,
})
}
async fn search_people(
&self,
query: &SearchQuery,
) -> Result<Paginated<PersonSearchHit>, 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<String>,
profile_path: Option<String>,
tmdb_person_id: Option<i64>,
}
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,
})
}
}

View File

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

View File

@@ -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<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
}
impl MovieRow {
fn into_domain(self) -> Result<Movie, DomainError> {
let id = MovieId::from_uuid(parse_uuid(&self.id)?);
let external_metadata_id = self
.external_metadata_id
.map(ExternalMetadataId::new)
.transpose()?;
let title = MovieTitle::new(self.title)?;
let release_year = ReleaseYear::new(self.release_year as u16)?;
let poster_path = self.poster_path.map(PosterPath::new).transpose()?;
Ok(Movie::from_persistence(
id,
external_metadata_id,
title,
release_year,
self.director,
poster_path,
))
}
}
#[derive(sqlx::FromRow)]
struct ReviewRow {
id: String,
movie_id: String,
user_id: String,
rating: i64,
comment: Option<String>,
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
spoilers: bool,
}
impl ReviewRow {
fn into_domain(self) -> Result<Review, DomainError> {
let id = ReviewId::from_uuid(parse_uuid(&self.id)?);
let movie_id = MovieId::from_uuid(parse_uuid(&self.movie_id)?);
let user_id = UserId::from_uuid(parse_uuid(&self.user_id)?);
let rating = Rating::new(self.rating as u8)?;
let comment = self.comment.map(Comment::new).transpose()?;
let watched_at = parse_datetime(&self.watched_at)?;
let created_at = parse_datetime(&self.created_at)?;
let source = match self.remote_actor_url {
None => ReviewSource::Local,
Some(url) => ReviewSource::Remote { actor_url: url },
};
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<String>,
title: String,
release_year: i64,
director: Option<String>,
poster_path: Option<String>,
review_id: String,
movie_id: String,
user_id: String,
rating: i64,
comment: Option<String>,
watched_at: String,
created_at: String,
remote_actor_url: Option<String>,
watch_medium: Option<String>,
spoilers: bool,
}
impl DiaryRow {
fn into_domain(self) -> Result<DiaryEntry, DomainError> {
let movie = MovieRow {
id: self.id,
external_metadata_id: self.external_metadata_id,
title: self.title,
release_year: self.release_year,
director: self.director,
poster_path: self.poster_path,
}
.into_domain()?;
let review = ReviewRow {
id: self.review_id,
movie_id: self.movie_id,
user_id: self.user_id,
rating: self.rating,
comment: self.comment,
watched_at: self.watched_at,
created_at: self.created_at,
remote_actor_url: self.remote_actor_url,
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<Vec<WatchlistWithMovie>, 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::<String, _>("id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
user_id: UserId::from_uuid(parse_uuid(
&row.try_get::<String, _>("user_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
movie_id: MovieId::from_uuid(parse_uuid(
&row.try_get::<String, _>("movie_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
added_at: parse_datetime(
&row.try_get::<String, _>("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<Vec<DiaryEntry>, 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<chrono::NaiveDateTime>,
limit: usize,
) -> Result<Vec<DiaryEntry>, 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()
}
}

View File

@@ -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<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = $1",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -1,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<FollowStatus> {
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<String> = row.try_get("local_username").ok().flatten();
let display: Option<String> = row.try_get("local_display").ok().flatten();
let avatar: Option<String> = row
.try_get::<Option<String>, _>("local_avatar_path")
.ok()
.flatten()
.map(|p| 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::<Option<String>, _>("remote_handle")
.ok()
.flatten()
.unwrap_or_else(|| actor_url.clone());
let display: Option<String> = row.try_get("remote_display").ok().flatten();
let avatar: Option<String> = row.try_get("remote_avatar").ok().flatten();
(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<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(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<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'accepted'",
)
.bind(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<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_followers f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(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<Vec<SocialActor>, DomainError> {
let uid = user_id.to_string();
let rows = sqlx::query(
"SELECT f.remote_actor_url,
u.username AS local_username, u.display_name AS local_display, u.avatar_path AS local_avatar_path,
a.handle AS remote_handle, a.display_name AS remote_display, a.avatar_url AS remote_avatar
FROM ap_following f
LEFT JOIN users u ON f.remote_actor_url = $1 || '/users/' || u.id
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $2 AND f.status = 'pending'",
)
.bind(self.instance.base_url())
.bind(&uid)
.fetch_all(&self.pool)
.await
.map_err(infra_err)?;
Ok(rows
.iter()
.map(|r| social_actor_from_row(r, &self.instance))
.collect())
}
async fn count_following(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_following WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn count_pending_followers(&self, user_id: uuid::Uuid) -> Result<usize, DomainError> {
let uid = user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'pending'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(count as usize)
}
async fn get_relation(
&self,
viewer_id: uuid::Uuid,
target_actor_url: &str,
) -> Result<FollowRelation, DomainError> {
let uid = viewer_id.to_string();
let row = sqlx::query(
"SELECT (SELECT status FROM ap_following WHERE local_user_id = $1 AND remote_actor_url = $2) AS following,
(SELECT status FROM ap_followers WHERE local_user_id = $1 AND remote_actor_url = $2) AS followed_by",
)
.bind(&uid)
.bind(target_actor_url)
.fetch_one(&self.pool)
.await
.map_err(infra_err)?;
Ok(FollowRelation {
following: row
.try_get::<Option<String>, _>("following")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
followed_by: row
.try_get::<Option<String>, _>("followed_by")
.map_err(infra_err)?
.as_deref()
.and_then(follow_status_from_str),
})
}
}

View File

@@ -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<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(PostgresSocialRepository::new(pool, instance))
}

View File

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

View File

@@ -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<Vec<RemoteActorInfo>, DomainError> {
let rows = sqlx::query_as::<_, (String, String, Option<String>)>(
"SELECT DISTINCT ar.url, ar.handle, ar.display_name FROM ap_remote_actors ar JOIN ap_following f ON f.remote_actor_url = ar.url WHERE f.status = 'accepted'",
).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())
}
}

View File

@@ -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<Vec<RemoteWatchlistEntry>, 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::<i32, _>("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::<chrono::DateTime<chrono::Utc>, _>("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<Vec<RemoteWatchlistEntry>, DomainError> {
let actors: Vec<String> =
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::<String, _>("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,
}
}
}

View File

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

View File

@@ -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
);

View File

@@ -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);

View File

@@ -1,2 +0,0 @@
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN avatar_path TEXT;

View File

@@ -1 +0,0 @@
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;

View File

@@ -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);

View File

@@ -1,5 +0,0 @@
CREATE TABLE blocked_domains (
domain TEXT PRIMARY KEY,
reason TEXT,
blocked_at TEXT NOT NULL
);

View File

@@ -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)
);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -1 +0,0 @@
ALTER TABLE ap_remote_actors ADD COLUMN outbox_url TEXT;

View File

@@ -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);

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT;

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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);

View File

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

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -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<i64, DomainError> {
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<Vec<DiaryRow>, 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<Vec<DiaryRow>, 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<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title 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<Vec<DiaryRow>, 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<Paginated<DiaryEntry>, DomainError> {
let limit = filter.page.limit as i64;
let offset = filter.page.offset as i64;
let (total, rows) = match (&filter.movie_id, &filter.user_id) {
(None, None) => tokio::try_join!(
self.count_diary_entries(None),
self.fetch_all_diary_rows(&filter.sort_by, limit, offset)
)?,
(Some(id), None) => {
let id_str = id.value().to_string();
tokio::try_join!(
self.count_diary_entries(Some(id_str.as_str())),
self.fetch_movie_diary_rows(&id_str, &filter.sort_by, limit, offset)
)?
}
(None, Some(uid)) => {
let uid_str = uid.value().to_string();
let search = filter.search.as_deref();
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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: filter.page.limit,
offset: filter.page.offset,
})
}
async fn query_activity_feed(
&self,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
self.query_activity_feed_filtered(page, &domain::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<Paginated<FeedEntry>, 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<String> = f.local_user_ids.iter().map(|_| next_param()).collect();
let remote_params: Vec<String> =
f.remote_actor_urls.iter().map(|_| next_param()).collect();
let local_in = if local_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
local_params.join(", ")
};
let remote_in = if remote_params.is_empty() {
"(SELECT NULL::text WHERE false)".to_string()
} else {
remote_params.join(", ")
};
where_parts.push(format!(
"(r.user_id IN ({}) OR r.remote_actor_url IN ({}))",
local_in, remote_in
));
}
let limit_param = next_param();
let offset_param = next_param();
let order_clause = match sort_by {
FeedSortBy::Date => "r.watched_at DESC",
FeedSortBy::DateAsc => "r.watched_at ASC",
FeedSortBy::Rating => "r.rating DESC, r.watched_at DESC",
FeedSortBy::RatingAsc => "r.rating ASC, r.watched_at ASC",
};
let where_clause = where_parts.join(" AND ");
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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn get_review_history(&self, movie_id: &MovieId) -> Result<ReviewHistory, DomainError> {
let id_str = movie_id.value().to_string();
let movie = sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.map_err(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::<Result<Vec<_>, _>>()?;
Ok(ReviewHistory::new(movie, viewings))
}
async fn get_user_history(&self, user_id: &UserId) -> Result<Vec<DiaryEntry>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
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<DiaryEntry, DomainError>> {
let pool = self.pool.clone();
let uid = user_id.value().to_string();
Box::pin(async_stream::stream! {
let mut rows = sqlx::query_as::<_, DiaryRow>(
"SELECT m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path,
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment,
to_char(r.watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(r.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
r.remote_actor_url,
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<MovieStats, DomainError> {
let id_str = movie_id.value().to_string();
sqlx::query_as::<_, MovieStatsRow>(
"SELECT
COUNT(*) AS total_count,
AVG(CAST(rating AS FLOAT)) AS avg_rating,
COUNT(CASE WHEN remote_actor_url IS NOT NULL THEN 1 END) AS federated_count,
COUNT(CASE WHEN rating = 1 THEN 1 END) AS rating_1,
COUNT(CASE WHEN rating = 2 THEN 1 END) AS rating_2,
COUNT(CASE WHEN rating = 3 THEN 1 END) AS rating_3,
COUNT(CASE WHEN rating = 4 THEN 1 END) AS rating_4,
COUNT(CASE WHEN rating = 5 THEN 1 END) AS rating_5
FROM reviews WHERE movie_id = $1",
)
.bind(id_str)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
.map(MovieStatsRow::into_domain)
}
async fn get_movie_social_feed(
&self,
movie_id: &MovieId,
page: &PageParams,
) -> Result<Paginated<FeedEntry>, DomainError> {
let id_str = movie_id.value().to_string();
let limit = page.limit as i64;
let offset = page.offset as i64;
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE movie_id = $1")
.bind(&id_str)
.fetch_one(&self.pool)
.await
.map_err(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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn count_local_posts(&self) -> Result<u64, DomainError> {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE remote_actor_url IS NULL")
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(count as u64)
}
}

View File

@@ -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<Option<Goal>, 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<Vec<Goal>, DomainError> {
let uid = user_id.value().to_string();
let rows = sqlx::query(
"SELECT id, user_id, year, target_count, goal_type, \
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at \
FROM goals WHERE user_id = $1 ORDER BY 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<u32, DomainError> {
let uid = user_id.value().to_string();
let start = format!("{year}-01-01 00:00:00");
let end = format!("{}-01-01 00:00:00", year + 1);
let count: i64 = sqlx::query(
"SELECT COUNT(*) FROM reviews \
WHERE user_id = $1 \
AND watched_at >= $2::timestamptz \
AND watched_at < $3::timestamptz \
AND remote_actor_url IS NULL",
)
.bind(&uid)
.bind(&start)
.bind(&end)
.fetch_one(pool)
.await
.map_err(|e| {
tracing::error!("Database error: {:?}", e);
DomainError::InfrastructureError("Database operation failed".into())
})?
.try_get(0)
.unwrap_or(0);
Ok(count as u32)
}
pub(crate) fn row_to_goal(r: &sqlx::postgres::PgRow) -> Result<Goal, DomainError> {
let id_str: String = r
.try_get("id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal id: {e}")))?;
let user_id_str: String = r
.try_get("user_id")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read user_id: {e}")))?;
let year: i64 = r
.try_get("year")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read year: {e}")))?;
let target: i64 = r.try_get("target_count").map_err(|e| {
DomainError::InfrastructureError(format!("Failed to read target_count: {e}"))
})?;
let goal_type_str: String = r
.try_get("goal_type")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read goal_type: {e}")))?;
let created_at_str: String = r
.try_get("created_at")
.map_err(|e| DomainError::InfrastructureError(format!("Failed to read created_at: {e}")))?;
let id = GoalId::from_uuid(parse_uuid(&id_str)?);
let user_id = UserId::from_uuid(parse_uuid(&user_id_str)?);
let goal_type: GoalType = goal_type_str.parse()?;
let created_at = parse_datetime(&created_at_str)?;
Ok(Goal::from_persistence(
id,
user_id,
year as u16,
target as u32,
goal_type,
created_at,
))
}

View File

@@ -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<dyn ImageRefCommand>, Arc<dyn ImageRefQuery>) {
let adapter = Arc::new(PostgresImageRefAdapter::new(pool));
(
Arc::clone(&adapter) as Arc<dyn ImageRefCommand>,
adapter as Arc<dyn ImageRefQuery>,
)
}
#[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<Vec<String>, 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())
}
}

View File

@@ -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<String, DomainError> {
serde_json::to_string(&ms.iter().map(mapping_to_json).collect::<Vec<_>>())
.map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
fn deserialize_mappings(s: &str) -> Result<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> =
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<Vec<ImportProfile>, 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::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.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<Option<ImportProfile>, 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::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
r.user_id
.parse::<uuid::Uuid>()
.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)
}
}

View File

@@ -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<String>,
rows: Vec<Vec<String>>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
release_year: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
director: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
rating: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
watched_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
external_metadata_id: Option<String>,
}
#[derive(Serialize, Deserialize)]
enum RowResultJson {
Valid(ImportRowJson),
Invalid {
errors: Vec<String>,
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<T: Serialize>(v: &T) -> Result<String, DomainError> {
serde_json::to_string(v).map_err(|e| DomainError::InfrastructureError(e.to_string()))
}
fn de<T: for<'de> Deserialize<'de>>(s: &str) -> Result<T, DomainError> {
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<String>, Option<String>), 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::<Vec<_>>()))
.transpose()?;
let results = s
.row_results
.as_ref()
.map(|rs| ser(&rs.iter().map(annotated_to_json).collect::<Vec<_>>()))
.transpose()?;
Ok((parsed, mappings, results))
}
fn deserialize_session(
id: String,
user_id: String,
parsed_data: String,
field_mappings: Option<String>,
row_results: Option<String>,
created_at: NaiveDateTime,
expires_at: NaiveDateTime,
) -> Result<ImportSession, DomainError> {
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<Vec<FieldMapping>, DomainError> {
let js: Vec<FieldMappingJson> = de(s)?;
Ok(js.into_iter().map(mapping_from_json).collect())
})
.transpose()?;
let row_results = row_results
.as_deref()
.map(|s| -> Result<Vec<AnnotatedRow>, DomainError> {
let js: Vec<AnnotatedRowJson> = de(s)?;
Ok(js.into_iter().map(annotated_from_json).collect())
})
.transpose()?;
Ok(ImportSession {
id: ImportSessionId::from_uuid(
id.parse::<uuid::Uuid>()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
),
user_id: UserId::from_uuid(
user_id
.parse::<uuid::Uuid>()
.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<Option<ImportSession>, 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<String>,
row_results: Option<String>,
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<u64, DomainError> {
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)
}
}

View File

@@ -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<Vec<WatchRow>, 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<String> = 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<String, Vec<String>> =
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<String> = 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)
}
}

View File

@@ -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<dyn domain::ports::UserProfileFieldsRepository> {
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
}
pub struct PostgresWireOutput {
pub pool: PgPool,
pub movie_command: std::sync::Arc<dyn domain::ports::MovieCommand>,
pub movie_query: std::sync::Arc<dyn domain::ports::MovieQuery>,
pub review: std::sync::Arc<dyn domain::ports::ReviewRepository>,
pub diary: std::sync::Arc<dyn domain::ports::DiaryQuery>,
pub stats: std::sync::Arc<dyn domain::ports::StatsRepository>,
pub user: std::sync::Arc<dyn domain::ports::UserRepository>,
pub import_session: std::sync::Arc<dyn domain::ports::ImportSessionRepository>,
pub import_profile: std::sync::Arc<dyn domain::ports::ImportProfileRepository>,
pub movie_profile: std::sync::Arc<dyn domain::ports::MovieProfileRepository>,
pub watchlist: std::sync::Arc<dyn domain::ports::WatchlistRepository>,
pub ap_content: std::sync::Arc<dyn domain::ports::LocalApContentQuery>,
pub wrapup_repo: std::sync::Arc<dyn domain::ports::WrapUpRepository>,
pub wrapup_stats: std::sync::Arc<dyn domain::ports::WrapUpStatsQuery>,
pub insights: std::sync::Arc<dyn domain::ports::InsightsQuery>,
pub goal_command: std::sync::Arc<dyn domain::ports::GoalCommand>,
pub goal_query: std::sync::Arc<dyn domain::ports::GoalQuery>,
pub user_settings: std::sync::Arc<dyn domain::ports::UserSettingsRepository>,
pub federation_settings: std::sync::Arc<dyn domain::ports::UserFederationSettingsQuery>,
pub remote_goal: std::sync::Arc<dyn domain::ports::RemoteGoalRepository>,
pub deduplicator: std::sync::Arc<dyn domain::ports::MovieDeduplicator>,
}
pub async fn wire(database_url: &str) -> anyhow::Result<PostgresWireOutput> {
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 _,
})
}

View File

@@ -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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
}
impl MovieRow {
pub fn into_domain(self) -> Result<Movie, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub genres: Option<Vec<String>>,
pub runtime_minutes: Option<i64>,
pub original_language: Option<String>,
pub overview: Option<String>,
pub collection_name: Option<String>,
}
impl MovieSummaryRow {
pub fn into_domain(self) -> Result<MovieSummary, DomainError> {
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<String>,
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub spoilers: bool,
}
impl ReviewRow {
pub fn into_domain(self) -> Result<Review, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub review_id: String,
pub movie_id: String,
pub user_id: String,
pub rating: i64,
pub comment: Option<String>,
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub spoilers: bool,
}
impl DiaryRow {
pub fn into_domain(self) -> Result<DiaryEntry, DomainError> {
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<String>,
pub title: String,
pub release_year: i64,
pub director: Option<String>,
pub poster_path: Option<String>,
pub review_id: String,
pub movie_id: String,
pub user_id: String,
pub rating: i64,
pub comment: Option<String>,
pub watched_at: String,
pub created_at: String,
pub remote_actor_url: Option<String>,
pub watch_medium: Option<String>,
pub spoilers: bool,
pub user_email: String,
}
impl FeedRow {
pub fn into_domain(self) -> Result<FeedEntry, DomainError> {
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<f64>,
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<String>,
pub total_movies: i64,
pub avg_rating: Option<f64>,
pub avatar_path: Option<String>,
}
impl UserSummaryRow {
pub fn into_domain(self) -> Result<domain::models::UserSummary, DomainError> {
user_summary_to_domain(
self.id,
self.email,
self.username,
self.display_name,
self.total_movies,
self.avg_rating,
self.avatar_path,
)
}
}
#[derive(sqlx::FromRow)]
pub(crate) struct UserTotalsRow {
pub total: i64,
pub avg_rating: Option<f64>,
}
#[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<String>,
pub billing_order: i32,
pub movie_id: String,
}

View File

@@ -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<Option<Movie>, DomainError> {
let id = external_metadata_id.value();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE external_metadata_id = $1",
)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movie_by_id(&self, movie_id: &MovieId) -> Result<Option<Movie>, DomainError> {
let id = movie_id.value().to_string();
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE id = $1",
)
.bind(&id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.map(MovieRow::into_domain)
.transpose()
}
async fn get_movies_by_title_and_year(
&self,
title: &MovieTitle,
year: &ReleaseYear,
) -> Result<Vec<Movie>, DomainError> {
let title = title.value();
let year = year.value() as i64;
sqlx::query_as::<_, MovieRow>(
"SELECT id, external_metadata_id, title, release_year, director, poster_path
FROM movies WHERE title = $1 AND release_year = $2",
)
.bind(title)
.bind(year)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?
.into_iter()
.map(MovieRow::into_domain)
.collect()
}
async fn existing_external_ids(
&self,
ids: &[ExternalMetadataId],
) -> Result<std::collections::HashSet<String>, DomainError> {
if ids.is_empty() {
return Ok(Default::default());
}
let vals: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let rows: Vec<(String,)> = sqlx::query_as(
"SELECT external_metadata_id FROM movies WHERE external_metadata_id = ANY($1)",
)
.bind(&vals)
.fetch_all(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(rows.into_iter().map(|(id,)| id).collect())
}
async fn existing_title_year_pairs(
&self,
pairs: &[(MovieTitle, ReleaseYear)],
) -> Result<std::collections::HashSet<(String, u16)>, DomainError> {
if pairs.is_empty() {
return Ok(Default::default());
}
let titles: Vec<&str> = pairs.iter().map(|(t, _)| t.value()).collect();
let years: Vec<i64> = pairs.iter().map(|(_, y)| y.value() as i64).collect();
use sqlx::Row;
let rows = sqlx::query(
"SELECT DISTINCT m.title, m.release_year FROM movies m \
INNER JOIN unnest($1::text[], $2::bigint[]) AS p(title, release_year) \
ON m.title = p.title AND m.release_year = p.release_year",
)
.bind(&titles)
.bind(&years)
.fetch_all(&self.pool)
.await
.map_err(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<Paginated<MovieSummary>, DomainError> {
use sqlx::Row;
let limit = page.limit as i64;
let offset = page.offset as i64;
let pattern = filter
.search
.as_deref()
.map(|s| format!("%{}%", s.to_lowercase()));
let genre = filter.genre.as_deref();
let language = filter.language.as_deref();
let rows: Vec<MovieSummaryRow> = sqlx::query_as(
"SELECT \
m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name, \
array_agg(g.name) FILTER (WHERE g.name IS NOT NULL) AS genres \
FROM movies m \
LEFT JOIN movie_profiles p ON p.movie_id = m.id \
LEFT JOIN movie_genres g ON g.movie_id = m.id \
WHERE ($1::text IS NULL OR LOWER(m.title) LIKE $1) \
AND ($2::text IS NULL OR p.original_language = $2) \
AND ($3::text IS NULL OR m.id IN (SELECT movie_id FROM movie_genres WHERE LOWER(name) = LOWER($3))) \
GROUP BY m.id, m.external_metadata_id, m.title, m.release_year, m.director, m.poster_path, \
p.overview, p.runtime_minutes, p.original_language, p.collection_name \
ORDER BY m.title ASC \
LIMIT $4 OFFSET $5",
)
.bind(&pattern)
.bind(language)
.bind(genre)
.bind(limit)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(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::<Result<Vec<_>, _>>()?;
Ok(Paginated {
items,
total_count: total as u64,
limit: page.limit,
offset: page.offset,
})
}
async fn list_movies_with_external_id(&self) -> Result<Vec<Movie>, 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()
}
}

View File

@@ -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<u64, DomainError> {
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)
}
}

View File

@@ -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<dyn PersonCommand>, Arc<dyn PersonQuery>) {
let adapter = Arc::new(PostgresPersonAdapter::new(pool));
(
Arc::clone(&adapter) as Arc<dyn PersonCommand>,
adapter as Arc<dyn PersonQuery>,
)
}
#[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<String>,
profile_path: Option<String>,
}
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<Option<Person>, 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<Option<Person>, 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<PersonCredits, DomainError> {
let person = self
.get_by_id(id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("Person {} not found", id.value())))?;
let tmdb_id: Option<i64> =
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<i32>,
character: String,
poster_path: Option<String>,
}
#[derive(sqlx::FromRow)]
struct CrewRow {
id: String,
title: String,
release_year: Option<i32>,
job: String,
department: String,
poster_path: Option<String>,
}
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<Vec<Person>, 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<Vec<PersonId>, 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<String>,
profile_path: Option<String>,
biography: Option<String>,
birthday: Option<String>,
deathday: Option<String>,
place_of_birth: Option<String>,
also_known_as: Option<String>,
homepage: Option<String>,
imdb_id: Option<String>,
enriched_at: Option<String>,
}
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::<Vec<String>>(&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,
)
}
}

View File

@@ -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<Option<MovieProfile>, 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<Utc> = 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::<i32, _>("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::<i32, _>("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::<i64, _>("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::<i32, _>("billing_order").unwrap_or(0) as u32,
profile_path: r
.try_get::<Option<String>, _>("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::<i64, _>("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::<Option<String>, _>("profile_path")
.ok()
.flatten(),
})
.collect();
Ok(Some(MovieProfile {
movie_id: id.clone(),
tmdb_id: row.try_get::<i64, _>("tmdb_id").unwrap_or(0) as u64,
imdb_id: row.try_get::<Option<String>, _>("imdb_id").ok().flatten(),
overview: row.try_get::<Option<String>, _>("overview").ok().flatten(),
tagline: row.try_get::<Option<String>, _>("tagline").ok().flatten(),
runtime_minutes: row
.try_get::<Option<i32>, _>("runtime_minutes")
.ok()
.flatten()
.map(|v| v as u32),
budget_usd: row.try_get::<Option<i64>, _>("budget_usd").ok().flatten(),
revenue_usd: row.try_get::<Option<i64>, _>("revenue_usd").ok().flatten(),
vote_average: row.try_get::<Option<f64>, _>("vote_average").ok().flatten(),
vote_count: row
.try_get::<Option<i32>, _>("vote_count")
.ok()
.flatten()
.map(|v| v as u32),
original_language: row
.try_get::<Option<String>, _>("original_language")
.ok()
.flatten(),
collection_name: row
.try_get::<Option<String>, _>("collection_name")
.ok()
.flatten(),
genres,
keywords,
cast,
crew,
enriched_at,
}))
}
async fn list_stale(&self) -> Result<Vec<(MovieId, String)>, 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<String> = 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::<uuid::Uuid>().ok().map(MovieId::from_uuid)?;
Some((movie_id, ext_id))
})
.collect())
}
}

View File

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

View File

@@ -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<Option<RefreshSession>, 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<u64, DomainError> {
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<RefreshSession, DomainError> {
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,
})
}
}

View File

@@ -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<Option<Review>, DomainError> {
let id = review_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url,
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<Vec<Review>, DomainError> {
let uid = user_id.value().to_string();
sqlx::query_as::<_, ReviewRow>(
"SELECT id, movie_id, user_id, rating, comment,
to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS watched_at,
to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS') AS created_at,
remote_actor_url,
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()
}
}

View File

@@ -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<UserTotalsRow, DomainError> {
sqlx::query_as::<_, UserTotalsRow>(
r#"SELECT COUNT(DISTINCT movie_id) AS total,
AVG(rating::float) AS avg_rating
FROM reviews WHERE user_id = $1"#,
)
.bind(user_id)
.fetch_one(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_favorite_director(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT m.director
FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.director IS NOT NULL
GROUP BY m.director
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
}
async fn fetch_user_most_active_month(
&self,
user_id: &str,
) -> Result<Option<String>, DomainError> {
sqlx::query_scalar::<_, String>(
"SELECT to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM') AS month
FROM reviews
WHERE user_id = $1
GROUP BY to_char(watched_at AT TIME ZONE 'UTC', 'YYYY-MM')
ORDER BY COUNT(*) DESC
LIMIT 1",
)
.bind(user_id)
.fetch_optional(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)
}
}
#[async_trait]
impl StatsRepository for PostgresStatsRepository {
async fn get_user_stats(&self, user_id: &UserId) -> Result<UserStats, DomainError> {
let uid = user_id.value().to_string();
let (totals, fav_director, most_active) = tokio::try_join!(
self.fetch_user_totals(&uid),
self.fetch_user_favorite_director(&uid),
self.fetch_user_most_active_month(&uid)
)?;
let most_active_month = most_active.map(|ym| format_year_month(&ym));
Ok(UserStats {
total_movies: totals.total,
avg_rating: totals.avg_rating,
favorite_director: fav_director,
most_active_month,
})
}
async fn count_reviews_in_year(&self, user_id: &UserId, year: u16) -> Result<u32, DomainError> {
crate::goals::count_reviews_in_year(&self.pool, user_id, year).await
}
async fn get_user_trends(&self, user_id: &UserId) -> Result<UserTrends, DomainError> {
let uid = user_id.value().to_string();
let (rating_rows, director_rows, 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,
})
}
}

View File

@@ -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<UserSettings, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = $1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(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<FederationFlags, DomainError> {
let uid = user_id.value().to_string();
let row = sqlx::query(
"SELECT federate_goals, federate_reviews, federate_watchlist \
FROM user_settings WHERE user_id = $1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(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,
}),
}
}
}

View File

@@ -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<ProfileField>,
) -> Result<User, DomainError> {
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::<String, _>("email"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let username = Username::new(row.get::<String, _>("username"))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let hash = PasswordHash::new(row.get::<String, _>("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<Option<User>, 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<Option<User>, 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<Option<User>, 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<Vec<domain::models::UserSummary>, 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()
}
}

View File

@@ -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<u64, DomainError> {
if ids.is_empty() {
return Ok(0);
}
let id_strs: Vec<String> = ids.iter().map(|id| id.value().to_string()).collect();
let status_str = status.to_string();
let result = sqlx::query("UPDATE watch_events SET status = $1 WHERE id = ANY($2)")
.bind(&status_str)
.bind(&id_strs)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
async fn delete_non_pending_older_than(
&self,
before: chrono::NaiveDateTime,
) -> Result<u64, DomainError> {
let result =
sqlx::query("DELETE FROM watch_events WHERE status != 'pending' AND created_at < $1")
.bind(before)
.execute(&self.pool)
.await
.map_err(adapter_common::map_sqlx_error)?;
Ok(result.rows_affected())
}
}
#[async_trait]
impl WatchEventQuery for PostgresWatchEventRepository {
async fn list_pending(&self, user_id: &UserId) -> Result<Vec<WatchEvent>, DomainError> {
let uid = user_id.value().to_string();
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<Option<WatchEvent>, 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<Vec<WatchEvent>, DomainError> {
if ids.is_empty() {
return Ok(vec![]);
}
let id_strs: Vec<String> = 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<bool, DomainError> {
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<WatchEvent, DomainError> {
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let movie_id_str: Option<String> = row
.try_get("movie_id")
.map_err(adapter_common::map_sqlx_error)?;
let title: String = row
.try_get("title")
.map_err(adapter_common::map_sqlx_error)?;
let year: Option<i32> = row
.try_get("year")
.map_err(adapter_common::map_sqlx_error)?;
let ext_id: Option<String> = row
.try_get("external_metadata_id")
.map_err(adapter_common::map_sqlx_error)?;
let source_str: String = row
.try_get("source")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let source: WatchEventSource = source_str
.parse()
.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<Option<WebhookToken>, 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<Vec<WebhookToken>, 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<WebhookToken, DomainError> {
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let token_hash: String = row
.try_get("token_hash")
.map_err(adapter_common::map_sqlx_error)?;
let provider_str: String = row
.try_get("provider")
.map_err(adapter_common::map_sqlx_error)?;
let label: Option<String> = row
.try_get("label")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let last_used_str: Option<String> = row
.try_get("last_used_at")
.map_err(adapter_common::map_sqlx_error)?;
let provider: WatchEventSource = provider_str
.parse()
.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,
))
}

View File

@@ -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<bool, 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)?;
Ok(result.rows_affected() > 0)
}
async fn get_for_user(
&self,
user_id: &UserId,
page: &PageParams,
) -> Result<Paginated<WatchlistWithMovie>, 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::<String, _>("id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
user_id: UserId::from_uuid(parse_uuid(
&row.try_get::<String, _>("user_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
movie_id: MovieId::from_uuid(parse_uuid(
&row.try_get::<String, _>("movie_id")
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
)?),
added_at: parse_datetime(
&row.try_get::<String, _>("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::<Result<Vec<_>, 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<bool, DomainError> {
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)
}
}

View File

@@ -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<WrapUpStatus, DomainError> {
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<Option<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Vec<WrapUpRecord>, 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<Uuid>,
start: NaiveDate,
end: NaiveDate,
) -> Result<Option<WrapUpRecord>, 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<u64, DomainError> {
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<WrapUpRecord, DomainError> {
let id_str: String = row.try_get("id").map_err(adapter_common::map_sqlx_error)?;
let user_id_str: Option<String> = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let start_date: NaiveDate = row
.try_get("start_date")
.map_err(adapter_common::map_sqlx_error)?;
let end_date: NaiveDate = row
.try_get("end_date")
.map_err(adapter_common::map_sqlx_error)?;
let status_str: String = row
.try_get("status")
.map_err(adapter_common::map_sqlx_error)?;
let report_json: Option<String> = row
.try_get("report_json")
.map_err(adapter_common::map_sqlx_error)?;
let error_message: Option<String> = row
.try_get("error_message")
.map_err(adapter_common::map_sqlx_error)?;
let created_at_str: String = row
.try_get("created_at")
.map_err(adapter_common::map_sqlx_error)?;
let completed_at_str: Option<String> = row
.try_get("completed_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id = user_id_str.as_deref().map(parse_uuid).transpose()?;
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<Vec<WrapUpMovieRow>, 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<String> = 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<String> = row
.try_get("director")
.map_err(adapter_common::map_sqlx_error)?;
let poster_path: Option<String> = row
.try_get("poster_path")
.map_err(adapter_common::map_sqlx_error)?;
let rating: i64 = row
.try_get("rating")
.map_err(adapter_common::map_sqlx_error)?;
let watched_at_str: String = row
.try_get("watched_at")
.map_err(adapter_common::map_sqlx_error)?;
let user_id_str: String = row
.try_get("user_id")
.map_err(adapter_common::map_sqlx_error)?;
let runtime_minutes: Option<i32> = row
.try_get("runtime_minutes")
.map_err(adapter_common::map_sqlx_error)?;
let budget_usd: Option<i64> = row
.try_get("budget_usd")
.map_err(adapter_common::map_sqlx_error)?;
let original_language: Option<String> = row
.try_get("original_language")
.map_err(adapter_common::map_sqlx_error)?;
let watch_medium: Option<String> = row
.try_get("watch_medium")
.map_err(adapter_common::map_sqlx_error)?;
let genres = genres_map.get(&movie_id_str).cloned().unwrap_or_default();
let keywords = keywords_map.get(&movie_id_str).cloned().unwrap_or_default();
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<Option<String>> =
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<String>,
}
/// Shared by WrapUp and Insights: movie-id → genre names.
pub(crate) async fn fetch_genres_pg(
pool: &PgPool,
movie_ids: &[String],
) -> Result<HashMap<String, Vec<String>>, 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<String, Vec<String>> = 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<HashMap<String, Vec<String>>, 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<String, Vec<String>> = 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<HashMap<String, Vec<CastEntry>>, 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<String, Vec<CastEntry>> = 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<String> = row
.try_get("profile_path")
.map_err(adapter_common::map_sqlx_error)?;
map.entry(mid).or_default().push(CastEntry {
name,
billing_order: billing_order as u32,
tmdb_person_id,
profile_path,
});
}
Ok(map)
}

View File

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

View File

@@ -48,126 +48,59 @@ pub struct DatabaseOutput {
}
pub async fn build_database_adapters(
backend: &str,
url: &str,
instance: &domain::value_objects::InstanceIdentity,
) -> anyhow::Result<DatabaseOutput> {
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<dyn WebhookTokenRepository> = 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<dyn WebhookTokenRepository> =
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<dyn WebhookTokenRepository> =
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<dyn AuthService>, Arc<dyn PasswordHasher>)> {
@@ -190,8 +123,6 @@ pub fn build_profile_fields_repo(
pool: &DbPool,
) -> anyhow::Result<Arc<dyn UserProfileFieldsRepository>> {
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"))]

View File

@@ -10,5 +10,4 @@ anyhow = { workspace = true }
[features]
default = []
sqlite = []
postgres = ["sqlx/postgres"]
nats = []

View File

@@ -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)]

View File

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

View File

@@ -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"))

View File

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

View File

@@ -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?)

View File

@@ -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<dyn PersonEnrichmentClient>);
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"))