refactor/tighten-architecture #26
91
Cargo.lock
generated
91
Cargo.lock
generated
@@ -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",
|
||||
|
||||
10
Cargo.toml
10
Cargo.toml
@@ -8,13 +8,9 @@ members = [
|
||||
"crates/adapters/poster-sync",
|
||||
"crates/adapters/rss",
|
||||
"crates/adapters/sqlite",
|
||||
"crates/adapters/postgres",
|
||||
"crates/adapters/sqlite-federation",
|
||||
"crates/adapters/sqlite-social",
|
||||
"crates/adapters/postgres-federation",
|
||||
"crates/adapters/postgres-social",
|
||||
"crates/adapters/sqlite-event-queue",
|
||||
"crates/adapters/postgres-event-queue",
|
||||
"crates/adapters/template-askama",
|
||||
"crates/adapters/activitypub",
|
||||
"crates/adapters/export",
|
||||
@@ -34,7 +30,6 @@ members = [
|
||||
"crates/adapters/jellyfin",
|
||||
"crates/adapters/plex",
|
||||
"crates/adapters/sqlite-search",
|
||||
"crates/adapters/postgres-search",
|
||||
"crates/adapters/adapter-common",
|
||||
"crates/infra-wiring",
|
||||
]
|
||||
@@ -89,21 +84,16 @@ export = { path = "crates/adapters/export" }
|
||||
sqlite = { path = "crates/adapters/sqlite" }
|
||||
sqlite-federation = { path = "crates/adapters/sqlite-federation" }
|
||||
sqlite-social = { path = "crates/adapters/sqlite-social" }
|
||||
postgres = { path = "crates/adapters/postgres" }
|
||||
postgres-federation = { path = "crates/adapters/postgres-federation" }
|
||||
postgres-social = { path = "crates/adapters/postgres-social" }
|
||||
template-askama = { path = "crates/adapters/template-askama" }
|
||||
activitypub = { path = "crates/adapters/activitypub" }
|
||||
event-payload = { path = "crates/adapters/event-payload" }
|
||||
nats = { path = "crates/adapters/nats" }
|
||||
sqlite-event-queue = { path = "crates/adapters/sqlite-event-queue" }
|
||||
postgres-event-queue = { path = "crates/adapters/postgres-event-queue" }
|
||||
importer = { path = "crates/adapters/importer" }
|
||||
jellyfin = { path = "crates/adapters/jellyfin" }
|
||||
plex = { path = "crates/adapters/plex" }
|
||||
image-converter = { path = "crates/adapters/image-converter" }
|
||||
sqlite-search = { path = "crates/adapters/sqlite-search" }
|
||||
postgres-search = { path = "crates/adapters/postgres-search" }
|
||||
adapter-common = { path = "crates/adapters/adapter-common" }
|
||||
infra-wiring = { path = "crates/infra-wiring" }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}"))
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
pub use event_payload::EventPayload as DbEventPayload;
|
||||
@@ -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 }
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod followers;
|
||||
mod following;
|
||||
mod migration;
|
||||
@@ -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 _,
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
ALTER TABLE users ADD COLUMN avatar_path TEXT;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE ap_remote_actors ADD COLUMN avatar_url TEXT;
|
||||
@@ -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);
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE blocked_domains (
|
||||
domain TEXT PRIMARY KEY,
|
||||
reason TEXT,
|
||||
blocked_at TEXT NOT NULL
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
CREATE TABLE blocked_actors (
|
||||
local_user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
remote_actor_url TEXT NOT NULL,
|
||||
blocked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (local_user_id, remote_actor_url)
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE ap_remote_actors ADD COLUMN outbox_url TEXT;
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS display_name TEXT;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE reviews ADD COLUMN watch_medium TEXT;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE reviews ADD COLUMN spoilers BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 _,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -10,5 +10,4 @@ anyhow = { workspace = true }
|
||||
[features]
|
||||
default = []
|
||||
sqlite = []
|
||||
postgres = ["sqlx/postgres"]
|
||||
nats = []
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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?)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user