From fe9655ee96b06c12cf1895a45d80804d15dbc841 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Thu, 14 May 2026 03:32:56 +0200 Subject: [PATCH] feat(postgres): UserRepository --- crates/adapters/postgres/src/api_key.rs | 2 + crates/adapters/postgres/src/block.rs | 2 + crates/adapters/postgres/src/boost.rs | 2 + crates/adapters/postgres/src/feed.rs | 2 + crates/adapters/postgres/src/follow.rs | 2 + crates/adapters/postgres/src/lib.rs | 12 + crates/adapters/postgres/src/like.rs | 2 + crates/adapters/postgres/src/notification.rs | 2 + crates/adapters/postgres/src/remote_actor.rs | 2 + crates/adapters/postgres/src/tag.rs | 2 + crates/adapters/postgres/src/thought.rs | 2 + crates/adapters/postgres/src/top_friend.rs | 2 + crates/adapters/postgres/src/user.rs | 237 +++++++++++++++++++ 13 files changed, 271 insertions(+) create mode 100644 crates/adapters/postgres/src/api_key.rs create mode 100644 crates/adapters/postgres/src/block.rs create mode 100644 crates/adapters/postgres/src/boost.rs create mode 100644 crates/adapters/postgres/src/feed.rs create mode 100644 crates/adapters/postgres/src/follow.rs create mode 100644 crates/adapters/postgres/src/like.rs create mode 100644 crates/adapters/postgres/src/notification.rs create mode 100644 crates/adapters/postgres/src/remote_actor.rs create mode 100644 crates/adapters/postgres/src/tag.rs create mode 100644 crates/adapters/postgres/src/thought.rs create mode 100644 crates/adapters/postgres/src/top_friend.rs create mode 100644 crates/adapters/postgres/src/user.rs diff --git a/crates/adapters/postgres/src/api_key.rs b/crates/adapters/postgres/src/api_key.rs new file mode 100644 index 0000000..5c0089f --- /dev/null +++ b/crates/adapters/postgres/src/api_key.rs @@ -0,0 +1,2 @@ +pub struct PgApiKeyRepository { _pool: sqlx::PgPool } +impl PgApiKeyRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/block.rs b/crates/adapters/postgres/src/block.rs new file mode 100644 index 0000000..f7fc297 --- /dev/null +++ b/crates/adapters/postgres/src/block.rs @@ -0,0 +1,2 @@ +pub struct PgBlockRepository { _pool: sqlx::PgPool } +impl PgBlockRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/boost.rs b/crates/adapters/postgres/src/boost.rs new file mode 100644 index 0000000..d72d75d --- /dev/null +++ b/crates/adapters/postgres/src/boost.rs @@ -0,0 +1,2 @@ +pub struct PgBoostRepository { _pool: sqlx::PgPool } +impl PgBoostRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/feed.rs b/crates/adapters/postgres/src/feed.rs new file mode 100644 index 0000000..0f7e5ff --- /dev/null +++ b/crates/adapters/postgres/src/feed.rs @@ -0,0 +1,2 @@ +pub struct PgFeedRepository { _pool: sqlx::PgPool } +impl PgFeedRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/follow.rs b/crates/adapters/postgres/src/follow.rs new file mode 100644 index 0000000..e73692a --- /dev/null +++ b/crates/adapters/postgres/src/follow.rs @@ -0,0 +1,2 @@ +pub struct PgFollowRepository { _pool: sqlx::PgPool } +impl PgFollowRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index e69de29..0befdcd 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -0,0 +1,12 @@ +pub mod api_key; +pub mod block; +pub mod boost; +pub mod feed; +pub mod follow; +pub mod like; +pub mod notification; +pub mod remote_actor; +pub mod tag; +pub mod thought; +pub mod top_friend; +pub mod user; diff --git a/crates/adapters/postgres/src/like.rs b/crates/adapters/postgres/src/like.rs new file mode 100644 index 0000000..6970da9 --- /dev/null +++ b/crates/adapters/postgres/src/like.rs @@ -0,0 +1,2 @@ +pub struct PgLikeRepository { _pool: sqlx::PgPool } +impl PgLikeRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/notification.rs b/crates/adapters/postgres/src/notification.rs new file mode 100644 index 0000000..96e20e9 --- /dev/null +++ b/crates/adapters/postgres/src/notification.rs @@ -0,0 +1,2 @@ +pub struct PgNotificationRepository { _pool: sqlx::PgPool } +impl PgNotificationRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/remote_actor.rs b/crates/adapters/postgres/src/remote_actor.rs new file mode 100644 index 0000000..90fe94a --- /dev/null +++ b/crates/adapters/postgres/src/remote_actor.rs @@ -0,0 +1,2 @@ +pub struct PgRemoteActorRepository { _pool: sqlx::PgPool } +impl PgRemoteActorRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/tag.rs b/crates/adapters/postgres/src/tag.rs new file mode 100644 index 0000000..c78c388 --- /dev/null +++ b/crates/adapters/postgres/src/tag.rs @@ -0,0 +1,2 @@ +pub struct PgTagRepository { _pool: sqlx::PgPool } +impl PgTagRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/thought.rs b/crates/adapters/postgres/src/thought.rs new file mode 100644 index 0000000..d28bfd5 --- /dev/null +++ b/crates/adapters/postgres/src/thought.rs @@ -0,0 +1,2 @@ +pub struct PgThoughtRepository { _pool: sqlx::PgPool } +impl PgThoughtRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/top_friend.rs b/crates/adapters/postgres/src/top_friend.rs new file mode 100644 index 0000000..ee5896f --- /dev/null +++ b/crates/adapters/postgres/src/top_friend.rs @@ -0,0 +1,2 @@ +pub struct PgTopFriendRepository { _pool: sqlx::PgPool } +impl PgTopFriendRepository { pub fn new(pool: sqlx::PgPool) -> Self { Self { _pool: pool } } } diff --git a/crates/adapters/postgres/src/user.rs b/crates/adapters/postgres/src/user.rs new file mode 100644 index 0000000..457efc7 --- /dev/null +++ b/crates/adapters/postgres/src/user.rs @@ -0,0 +1,237 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use sqlx::PgPool; +use domain::{ + errors::DomainError, + models::{feed::UserSummary, user::User}, + ports::UserRepository, + value_objects::{Email, PasswordHash, UserId, Username}, +}; + +pub struct PgUserRepository { pool: PgPool } +impl PgUserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } } } + +#[derive(sqlx::FromRow)] +pub(crate) struct UserRow { + pub id: uuid::Uuid, + pub username: String, + pub email: String, + pub password_hash: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub header_url: Option, + pub custom_css: Option, + pub local: bool, + pub ap_id: Option, + pub inbox_url: Option, + pub public_key: Option, + pub private_key: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for User { + fn from(r: UserRow) -> Self { + User { + id: UserId::from_uuid(r.id), + username: Username::from_trusted(r.username), + email: Email::from_trusted(r.email), + password_hash: PasswordHash(r.password_hash), + display_name: r.display_name, + bio: r.bio, + avatar_url: r.avatar_url, + header_url: r.header_url, + custom_css: r.custom_css, + local: r.local, + ap_id: r.ap_id, + inbox_url: r.inbox_url, + public_key: r.public_key, + private_key: r.private_key, + created_at: r.created_at, + updated_at: r.updated_at, + } + } +} + +const USER_SELECT: &str = "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users"; + +#[async_trait] +impl UserRepository for PgUserRepository { + async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE id=$1")) + .bind(id.as_uuid()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|o| o.map(User::from)) + } + + async fn find_by_username(&self, username: &Username) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE username=$1")) + .bind(username.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|o| o.map(User::from)) + } + + async fn find_by_email(&self, email: &Email) -> Result, DomainError> { + sqlx::query_as::<_, UserRow>(&format!("{USER_SELECT} WHERE email=$1")) + .bind(email.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|o| o.map(User::from)) + } + + async fn save(&self, user: &User) -> Result<(), DomainError> { + sqlx::query( + "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) + ON CONFLICT(id) DO UPDATE SET + username=EXCLUDED.username, email=EXCLUDED.email, + password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name, + bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url, + header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css, + local=EXCLUDED.local, ap_id=EXCLUDED.ap_id, inbox_url=EXCLUDED.inbox_url, + public_key=EXCLUDED.public_key, private_key=EXCLUDED.private_key, + updated_at=NOW()" + ) + .bind(user.id.as_uuid()) + .bind(user.username.as_str()) + .bind(user.email.as_str()) + .bind(&user.password_hash.0) + .bind(&user.display_name) + .bind(&user.bio) + .bind(&user.avatar_url) + .bind(&user.header_url) + .bind(&user.custom_css) + .bind(user.local) + .bind(&user.ap_id) + .bind(&user.inbox_url) + .bind(&user.public_key) + .bind(&user.private_key) + .bind(user.created_at) + .bind(user.updated_at) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) + } + + async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { + sqlx::query( + "UPDATE users SET display_name=$2,bio=$3,avatar_url=$4,header_url=$5,custom_css=$6,updated_at=NOW() WHERE id=$1" + ) + .bind(user_id.as_uuid()) + .bind(display_name) + .bind(bio) + .bind(avatar_url) + .bind(header_url) + .bind(custom_css) + .execute(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string())) + .map(|_| ()) + } + + async fn list_with_stats(&self) -> Result, DomainError> { + #[derive(sqlx::FromRow)] + struct Row { + id: uuid::Uuid, + username: String, + display_name: Option, + avatar_url: Option, + bio: Option, + thought_count: i64, + follower_count: i64, + following_count: i64, + } + let rows = sqlx::query_as::<_, Row>( + "SELECT u.id, u.username, u.display_name, u.avatar_url, u.bio, + COUNT(DISTINCT t.id) AS thought_count, + COUNT(DISTINCT f1.follower_id) AS follower_count, + COUNT(DISTINCT f2.following_id) AS following_count + FROM users u + LEFT JOIN thoughts t ON t.user_id=u.id AND t.local=true + LEFT JOIN follows f1 ON f1.following_id=u.id AND f1.state='accepted' + LEFT JOIN follows f2 ON f2.follower_id=u.id AND f2.state='accepted' + WHERE u.local=true + GROUP BY u.id + ORDER BY u.username" + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::Internal(e.to_string()))?; + + Ok(rows.into_iter().map(|r| UserSummary { + id: UserId::from_uuid(r.id), + username: r.username, + display_name: r.display_name, + avatar_url: r.avatar_url, + bio: r.bio, + thought_count: r.thought_count, + follower_count: r.follower_count, + following_count: r.following_count, + }).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{models::user::User, value_objects::*}; + + #[sqlx::test(migrations = "./migrations")] + async fn save_and_find_by_id(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.username.as_str(), "alice"); + assert_eq!(found.email.as_str(), "alice@ex.com"); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_username_returns_none_when_missing(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let result = repo.find_by_username(&Username::new("ghost").unwrap()).await.unwrap(); + assert!(result.is_none()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn find_by_email(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + let found = repo.find_by_email(&Email::new("bob@ex.com").unwrap()).await.unwrap(); + assert!(found.is_some()); + } + + #[sqlx::test(migrations = "./migrations")] + async fn update_profile_changes_fields(pool: sqlx::PgPool) { + let repo = PgUserRepository::new(pool); + let user = User::new_local( + UserId::new(), + Username::new("charlie").unwrap(), + Email::new("charlie@ex.com").unwrap(), + PasswordHash("hash".into()), + ); + repo.save(&user).await.unwrap(); + repo.update_profile(&user.id, Some("Charlie".into()), Some("bio".into()), None, None, None).await.unwrap(); + let found = repo.find_by_id(&user.id).await.unwrap().unwrap(); + assert_eq!(found.display_name.as_deref(), Some("Charlie")); + assert_eq!(found.bio.as_deref(), Some("bio")); + } +}