diff --git a/crates/adapters/activitypub-base/src/actors.rs b/crates/adapters/activitypub-base/src/actors.rs index 08cdda6..befedc0 100644 --- a/crates/adapters/activitypub-base/src/actors.rs +++ b/crates/adapters/activitypub-base/src/actors.rs @@ -13,6 +13,7 @@ use url::Url; use crate::data::FederationData; use crate::error::Error; use crate::repository::RemoteActor; +use crate::user::ApProfileField; #[derive(Debug, Clone)] pub struct DbActor { @@ -31,7 +32,7 @@ pub struct DbActor { pub banner_url: Option, pub also_known_as: Option, pub profile_url: Option, - pub attachment: Vec, + pub attachment: Vec, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs index 5b24dee..2ac9c64 100644 --- a/crates/adapters/activitypub-base/src/lib.rs +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -24,4 +24,4 @@ pub use repository::{ BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, }; pub use service::ActivityPubService; -pub use user::{ApUser, ApUserRepository}; +pub use user::{ApProfileField, ApUser, ApUserRepository}; diff --git a/crates/adapters/activitypub-base/src/user.rs b/crates/adapters/activitypub-base/src/user.rs index 75cedd1..a99092b 100644 --- a/crates/adapters/activitypub-base/src/user.rs +++ b/crates/adapters/activitypub-base/src/user.rs @@ -1,6 +1,12 @@ use async_trait::async_trait; use url::Url; +#[derive(Debug, Clone)] +pub struct ApProfileField { + pub name: String, + pub value: String, +} + #[derive(Debug, Clone)] pub struct ApUser { pub id: uuid::Uuid, @@ -10,7 +16,7 @@ pub struct ApUser { pub banner_url: Option, pub also_known_as: Option, pub profile_url: Option, - pub attachment: Vec, + pub attachment: Vec, } #[async_trait] diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 2566008..a1854e1 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -31,7 +31,6 @@ pub async fn wire( review_store: std::sync::Arc, remote_watchlist_repo: std::sync::Arc, user_repo: std::sync::Arc, - profile_fields_repo: std::sync::Arc, movie_repo: std::sync::Arc, review_repo: std::sync::Arc, diary_repo: std::sync::Arc, @@ -67,7 +66,7 @@ pub async fn wire( let concrete = std::sync::Arc::new( ActivityPubService::new( federation_repo, - std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, profile_fields_repo, base_url.clone())), + std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())), composite, base_url.clone(), allow_registration, diff --git a/crates/adapters/activitypub/src/user_adapter.rs b/crates/adapters/activitypub/src/user_adapter.rs index e3cad15..9281dbd 100644 --- a/crates/adapters/activitypub/src/user_adapter.rs +++ b/crates/adapters/activitypub/src/user_adapter.rs @@ -1,30 +1,27 @@ use std::sync::Arc; -use activitypub_base::{ApUser, ApUserRepository}; +use activitypub_base::{ApProfileField, ApUser, ApUserRepository}; use async_trait::async_trait; use domain::{ - models::ProfileField, - ports::{UserProfileFieldsRepository, UserRepository}, + ports::UserRepository, value_objects::UserId, }; use url::Url; pub struct DomainUserRepoAdapter { pub repo: Arc, - pub fields_repo: Arc, pub base_url: String, } impl DomainUserRepoAdapter { pub fn new( repo: Arc, - fields_repo: Arc, base_url: String, ) -> Self { - Self { repo, fields_repo, base_url } + Self { repo, base_url } } - fn build_user(&self, u: &domain::models::User, fields: Vec) -> ApUser { + fn build_user(&self, u: &domain::models::User) -> ApUser { let avatar_url = u.avatar_path().and_then(|p| { Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() }); @@ -40,7 +37,7 @@ impl DomainUserRepoAdapter { banner_url, also_known_as: u.also_known_as().map(|s| s.to_string()), profile_url, - attachment: fields, + attachment: u.profile_fields().iter().map(|f| ApProfileField { name: f.name.clone(), value: f.value.clone() }).collect(), } } } @@ -53,8 +50,7 @@ impl ApUserRepository for DomainUserRepoAdapter { Some(u) => u, None => return Ok(None), }; - let fields = self.fields_repo.get_fields(&user_id).await.unwrap_or_default(); - Ok(Some(self.build_user(&user, fields))) + Ok(Some(self.build_user(&user))) } async fn find_by_username(&self, username: &str) -> anyhow::Result> { @@ -64,8 +60,7 @@ impl ApUserRepository for DomainUserRepoAdapter { Some(u) => u, None => return Ok(None), }; - let fields = self.fields_repo.get_fields(user.id()).await.unwrap_or_default(); - Ok(Some(self.build_user(&user, fields))) + Ok(Some(self.build_user(&user))) } async fn count_users(&self) -> anyhow::Result { diff --git a/crates/adapters/postgres/src/users.rs b/crates/adapters/postgres/src/users.rs index 1500b39..1581e1c 100644 --- a/crates/adapters/postgres/src/users.rs +++ b/crates/adapters/postgres/src/users.rs @@ -4,7 +4,7 @@ use sqlx::PgPool; use domain::{ errors::DomainError, - models::{User, UserRole}, + models::{ProfileField, User, UserRole}, ports::UserRepository, value_objects::{Email, PasswordHash, UserId, Username}, }; @@ -42,6 +42,7 @@ impl PostgresUserRepository { avatar_path: Option, banner_path: Option, also_known_as: Option, + profile_fields: Vec, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -61,6 +62,7 @@ impl PostgresUserRepository { avatar_path, banner_path, also_known_as, + profile_fields, )) } } @@ -99,6 +101,7 @@ impl UserRepository for PostgresUserRepository { r.avatar_path, r.banner_path, r.also_known_as, + vec![], ) }) .transpose() @@ -136,6 +139,7 @@ impl UserRepository for PostgresUserRepository { r.avatar_path, r.banner_path, r.also_known_as, + vec![], ) }) .transpose() @@ -200,20 +204,33 @@ impl UserRepository for PostgresUserRepository { .fetch_optional(&self.pool) .await .map_err(Self::map_err)?; - row.map(|r| { - Self::row_to_user( - r.id, - r.email, - r.username, - r.password_hash, - Self::parse_role(&r.role), - r.bio, - r.avatar_path, - r.banner_path, - r.also_known_as, - ) - }) - .transpose() + + let Some(r) = row else { return Ok(None) }; + + #[derive(sqlx::FromRow)] + struct FieldRow { name: String, value: String } + let field_rows = sqlx::query_as::<_, FieldRow>( + "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.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); + + Self::row_to_user( + r.id, + r.email, + r.username, + r.password_hash, + Self::parse_role(&r.role), + r.bio, + r.avatar_path, + r.banner_path, + r.also_known_as, + profile_fields, + ).map(Some) } async fn update_profile( diff --git a/crates/adapters/sqlite/src/tests/users.rs b/crates/adapters/sqlite/src/tests/users.rs index bbbf32f..e373e02 100644 --- a/crates/adapters/sqlite/src/tests/users.rs +++ b/crates/adapters/sqlite/src/tests/users.rs @@ -11,6 +11,12 @@ async fn setup() -> (SqlitePool, SqliteUserRepository) { .execute(&pool) .await .unwrap(); + sqlx::query( + "CREATE TABLE user_profile_fields (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, value TEXT NOT NULL, position INTEGER NOT NULL DEFAULT 0)" + ) + .execute(&pool) + .await + .unwrap(); let repo = SqliteUserRepository::new(pool.clone()); (pool, repo) } diff --git a/crates/adapters/sqlite/src/users.rs b/crates/adapters/sqlite/src/users.rs index 97ff8df..05a2203 100644 --- a/crates/adapters/sqlite/src/users.rs +++ b/crates/adapters/sqlite/src/users.rs @@ -5,7 +5,7 @@ use sqlx::SqlitePool; use super::models::UserSummaryRow; use domain::{ errors::DomainError, - models::{User, UserRole}, + models::{ProfileField, User, UserRole}, ports::UserRepository, value_objects::{Email, PasswordHash, UserId, Username}, }; @@ -41,6 +41,7 @@ impl SqliteUserRepository { avatar_path: Option, banner_path: Option, also_known_as: Option, + profile_fields: Vec, ) -> Result { let id = uuid::Uuid::parse_str(&id_str) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; @@ -60,6 +61,7 @@ impl SqliteUserRepository { avatar_path, banner_path, also_known_as, + profile_fields, )) } } @@ -87,6 +89,7 @@ impl UserRepository for SqliteUserRepository { r.avatar_path, r.banner_path, r.also_known_as, + vec![], ) }) .transpose() @@ -113,6 +116,7 @@ impl UserRepository for SqliteUserRepository { r.avatar_path, r.banner_path, r.also_known_as, + vec![], ) }) .transpose() @@ -163,20 +167,30 @@ impl UserRepository for SqliteUserRepository { .await .map_err(Self::map_err)?; - row.map(|r| { - Self::row_to_user( - r.id.unwrap_or_default(), - r.email, - r.username, - r.password_hash, - Self::parse_role(&r.role), - r.bio, - r.avatar_path, - r.banner_path, - r.also_known_as, - ) - }) - .transpose() + let Some(r) = row else { return Ok(None) }; + + let field_rows = sqlx::query!( + "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC", + id_str + ) + .fetch_all(&self.pool) + .await + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect(); + + Self::row_to_user( + r.id.unwrap_or_default(), + r.email, + r.username, + r.password_hash, + Self::parse_role(&r.role), + r.bio, + r.avatar_path, + r.banner_path, + r.also_known_as, + profile_fields, + ).map(Some) } async fn update_profile( diff --git a/crates/domain/src/models/mod.rs b/crates/domain/src/models/mod.rs index 2527e95..53232ab 100644 --- a/crates/domain/src/models/mod.rs +++ b/crates/domain/src/models/mod.rs @@ -323,6 +323,7 @@ pub struct User { avatar_path: Option, banner_path: Option, also_known_as: Option, + profile_fields: Vec, } impl User { @@ -342,6 +343,7 @@ impl User { avatar_path: None, banner_path: None, also_known_as: None, + profile_fields: vec![], } } @@ -355,6 +357,7 @@ impl User { avatar_path: Option, banner_path: Option, also_known_as: Option, + profile_fields: Vec, ) -> Self { Self { id, @@ -366,6 +369,7 @@ impl User { avatar_path, banner_path, also_known_as, + profile_fields, } } @@ -410,6 +414,10 @@ impl User { pub fn also_known_as(&self) -> Option<&str> { self.also_known_as.as_deref() } + + pub fn profile_fields(&self) -> &[ProfileField] { + &self.profile_fields + } } #[derive(Clone, Debug)] diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 01b1349..7ff216e 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -123,7 +123,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { review_store, remote_watchlist_repo.clone(), Arc::clone(&user_repository), - Arc::clone(&profile_fields_repo), Arc::clone(&movie_repository), Arc::clone(&review_repository), Arc::clone(&diary_repository), diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index de08fe4..c9bddcf 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -42,12 +42,11 @@ async fn main() -> anyhow::Result<()> { // Clone refs federation handler needs before ctx consumes them. #[cfg(feature = "federation")] - let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, fed_profile_fields_repo, base_url, allow_registration) = ( + let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = ( Arc::clone(&repos.movie), Arc::clone(&repos.review), Arc::clone(&repos.diary), Arc::clone(&repos.user), - Arc::clone(&repos.profile_fields), app_config.base_url.clone(), app_config.allow_registration, ); @@ -175,7 +174,6 @@ async fn main() -> anyhow::Result<()> { fed_review_store, fed_remote_watchlist_repo, fed_user_repo, - fed_profile_fields_repo, fed_movie_repo, fed_review_repo, fed_diary_repo,