feat: refactor user profile handling and integrate ApProfileField structure

This commit is contained in:
2026-05-13 22:59:38 +02:00
parent 815178e6a4
commit fdd61ae701
11 changed files with 94 additions and 51 deletions

View File

@@ -13,6 +13,7 @@ use url::Url;
use crate::data::FederationData; use crate::data::FederationData;
use crate::error::Error; use crate::error::Error;
use crate::repository::RemoteActor; use crate::repository::RemoteActor;
use crate::user::ApProfileField;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct DbActor { pub struct DbActor {
@@ -31,7 +32,7 @@ pub struct DbActor {
pub banner_url: Option<Url>, pub banner_url: Option<Url>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub profile_url: Option<Url>, pub profile_url: Option<Url>,
pub attachment: Vec<domain::models::ProfileField>, pub attachment: Vec<ApProfileField>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -24,4 +24,4 @@ pub use repository::{
BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, BlockedDomain, FederationRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor,
}; };
pub use service::ActivityPubService; pub use service::ActivityPubService;
pub use user::{ApUser, ApUserRepository}; pub use user::{ApProfileField, ApUser, ApUserRepository};

View File

@@ -1,6 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use url::Url; use url::Url;
#[derive(Debug, Clone)]
pub struct ApProfileField {
pub name: String,
pub value: String,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ApUser { pub struct ApUser {
pub id: uuid::Uuid, pub id: uuid::Uuid,
@@ -10,7 +16,7 @@ pub struct ApUser {
pub banner_url: Option<Url>, pub banner_url: Option<Url>,
pub also_known_as: Option<String>, pub also_known_as: Option<String>,
pub profile_url: Option<Url>, pub profile_url: Option<Url>,
pub attachment: Vec<domain::models::ProfileField>, pub attachment: Vec<ApProfileField>,
} }
#[async_trait] #[async_trait]

View File

@@ -31,7 +31,6 @@ pub async fn wire(
review_store: std::sync::Arc<dyn RemoteReviewRepository>, review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>, remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>, user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
profile_fields_repo: std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>, movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>, review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>, diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
@@ -67,7 +66,7 @@ pub async fn wire(
let concrete = std::sync::Arc::new( let concrete = std::sync::Arc::new(
ActivityPubService::new( ActivityPubService::new(
federation_repo, 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, composite,
base_url.clone(), base_url.clone(),
allow_registration, allow_registration,

View File

@@ -1,30 +1,27 @@
use std::sync::Arc; use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository}; use activitypub_base::{ApProfileField, ApUser, ApUserRepository};
use async_trait::async_trait; use async_trait::async_trait;
use domain::{ use domain::{
models::ProfileField, ports::UserRepository,
ports::{UserProfileFieldsRepository, UserRepository},
value_objects::UserId, value_objects::UserId,
}; };
use url::Url; use url::Url;
pub struct DomainUserRepoAdapter { pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>, pub repo: Arc<dyn UserRepository>,
pub fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub base_url: String, pub base_url: String,
} }
impl DomainUserRepoAdapter { impl DomainUserRepoAdapter {
pub fn new( pub fn new(
repo: Arc<dyn UserRepository>, repo: Arc<dyn UserRepository>,
fields_repo: Arc<dyn UserProfileFieldsRepository>,
base_url: String, base_url: String,
) -> Self { ) -> Self {
Self { repo, fields_repo, base_url } Self { repo, base_url }
} }
fn build_user(&self, u: &domain::models::User, fields: Vec<ProfileField>) -> ApUser { fn build_user(&self, u: &domain::models::User) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| { let avatar_url = u.avatar_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok() Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
}); });
@@ -40,7 +37,7 @@ impl DomainUserRepoAdapter {
banner_url, banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()), also_known_as: u.also_known_as().map(|s| s.to_string()),
profile_url, 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, Some(u) => u,
None => return Ok(None), None => return Ok(None),
}; };
let fields = self.fields_repo.get_fields(&user_id).await.unwrap_or_default(); Ok(Some(self.build_user(&user)))
Ok(Some(self.build_user(&user, fields)))
} }
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> { async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
@@ -64,8 +60,7 @@ impl ApUserRepository for DomainUserRepoAdapter {
Some(u) => u, Some(u) => u,
None => return Ok(None), None => return Ok(None),
}; };
let fields = self.fields_repo.get_fields(user.id()).await.unwrap_or_default(); Ok(Some(self.build_user(&user)))
Ok(Some(self.build_user(&user, fields)))
} }
async fn count_users(&self) -> anyhow::Result<usize> { async fn count_users(&self) -> anyhow::Result<usize> {

View File

@@ -4,7 +4,7 @@ use sqlx::PgPool;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{User, UserRole}, models::{ProfileField, User, UserRole},
ports::UserRepository, ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
@@ -42,6 +42,7 @@ impl PostgresUserRepository {
avatar_path: Option<String>, avatar_path: Option<String>,
banner_path: Option<String>, banner_path: Option<String>,
also_known_as: Option<String>, also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -61,6 +62,7 @@ impl PostgresUserRepository {
avatar_path, avatar_path,
banner_path, banner_path,
also_known_as, also_known_as,
profile_fields,
)) ))
} }
} }
@@ -99,6 +101,7 @@ impl UserRepository for PostgresUserRepository {
r.avatar_path, r.avatar_path,
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
vec![],
) )
}) })
.transpose() .transpose()
@@ -136,6 +139,7 @@ impl UserRepository for PostgresUserRepository {
r.avatar_path, r.avatar_path,
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
vec![],
) )
}) })
.transpose() .transpose()
@@ -200,20 +204,33 @@ impl UserRepository for PostgresUserRepository {
.fetch_optional(&self.pool) .fetch_optional(&self.pool)
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| {
Self::row_to_user( let Some(r) = row else { return Ok(None) };
r.id,
r.email, #[derive(sqlx::FromRow)]
r.username, struct FieldRow { name: String, value: String }
r.password_hash, let field_rows = sqlx::query_as::<_, FieldRow>(
Self::parse_role(&r.role), "SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
r.bio, )
r.avatar_path, .bind(&id_str)
r.banner_path, .fetch_all(&self.pool)
r.also_known_as, .await
) .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
})
.transpose() 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( async fn update_profile(

View File

@@ -11,6 +11,12 @@ async fn setup() -> (SqlitePool, SqliteUserRepository) {
.execute(&pool) .execute(&pool)
.await .await
.unwrap(); .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()); let repo = SqliteUserRepository::new(pool.clone());
(pool, repo) (pool, repo)
} }

View File

@@ -5,7 +5,7 @@ use sqlx::SqlitePool;
use super::models::UserSummaryRow; use super::models::UserSummaryRow;
use domain::{ use domain::{
errors::DomainError, errors::DomainError,
models::{User, UserRole}, models::{ProfileField, User, UserRole},
ports::UserRepository, ports::UserRepository,
value_objects::{Email, PasswordHash, UserId, Username}, value_objects::{Email, PasswordHash, UserId, Username},
}; };
@@ -41,6 +41,7 @@ impl SqliteUserRepository {
avatar_path: Option<String>, avatar_path: Option<String>,
banner_path: Option<String>, banner_path: Option<String>,
also_known_as: Option<String>, also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
) -> Result<User, DomainError> { ) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str) let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?; .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -60,6 +61,7 @@ impl SqliteUserRepository {
avatar_path, avatar_path,
banner_path, banner_path,
also_known_as, also_known_as,
profile_fields,
)) ))
} }
} }
@@ -87,6 +89,7 @@ impl UserRepository for SqliteUserRepository {
r.avatar_path, r.avatar_path,
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
vec![],
) )
}) })
.transpose() .transpose()
@@ -113,6 +116,7 @@ impl UserRepository for SqliteUserRepository {
r.avatar_path, r.avatar_path,
r.banner_path, r.banner_path,
r.also_known_as, r.also_known_as,
vec![],
) )
}) })
.transpose() .transpose()
@@ -163,20 +167,30 @@ impl UserRepository for SqliteUserRepository {
.await .await
.map_err(Self::map_err)?; .map_err(Self::map_err)?;
row.map(|r| { let Some(r) = row else { return Ok(None) };
Self::row_to_user(
r.id.unwrap_or_default(), let field_rows = sqlx::query!(
r.email, "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
r.username, id_str
r.password_hash, )
Self::parse_role(&r.role), .fetch_all(&self.pool)
r.bio, .await
r.avatar_path, .map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
r.banner_path,
r.also_known_as, let profile_fields = field_rows.into_iter().map(|f| ProfileField { name: f.name, value: f.value }).collect();
)
}) Self::row_to_user(
.transpose() 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( async fn update_profile(

View File

@@ -323,6 +323,7 @@ pub struct User {
avatar_path: Option<String>, avatar_path: Option<String>,
banner_path: Option<String>, banner_path: Option<String>,
also_known_as: Option<String>, also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
} }
impl User { impl User {
@@ -342,6 +343,7 @@ impl User {
avatar_path: None, avatar_path: None,
banner_path: None, banner_path: None,
also_known_as: None, also_known_as: None,
profile_fields: vec![],
} }
} }
@@ -355,6 +357,7 @@ impl User {
avatar_path: Option<String>, avatar_path: Option<String>,
banner_path: Option<String>, banner_path: Option<String>,
also_known_as: Option<String>, also_known_as: Option<String>,
profile_fields: Vec<ProfileField>,
) -> Self { ) -> Self {
Self { Self {
id, id,
@@ -366,6 +369,7 @@ impl User {
avatar_path, avatar_path,
banner_path, banner_path,
also_known_as, also_known_as,
profile_fields,
} }
} }
@@ -410,6 +414,10 @@ impl User {
pub fn also_known_as(&self) -> Option<&str> { pub fn also_known_as(&self) -> Option<&str> {
self.also_known_as.as_deref() self.also_known_as.as_deref()
} }
pub fn profile_fields(&self) -> &[ProfileField] {
&self.profile_fields
}
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -123,7 +123,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
review_store, review_store,
remote_watchlist_repo.clone(), remote_watchlist_repo.clone(),
Arc::clone(&user_repository), Arc::clone(&user_repository),
Arc::clone(&profile_fields_repo),
Arc::clone(&movie_repository), Arc::clone(&movie_repository),
Arc::clone(&review_repository), Arc::clone(&review_repository),
Arc::clone(&diary_repository), Arc::clone(&diary_repository),

View File

@@ -42,12 +42,11 @@ async fn main() -> anyhow::Result<()> {
// Clone refs federation handler needs before ctx consumes them. // Clone refs federation handler needs before ctx consumes them.
#[cfg(feature = "federation")] #[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.movie),
Arc::clone(&repos.review), Arc::clone(&repos.review),
Arc::clone(&repos.diary), Arc::clone(&repos.diary),
Arc::clone(&repos.user), Arc::clone(&repos.user),
Arc::clone(&repos.profile_fields),
app_config.base_url.clone(), app_config.base_url.clone(),
app_config.allow_registration, app_config.allow_registration,
); );
@@ -175,7 +174,6 @@ async fn main() -> anyhow::Result<()> {
fed_review_store, fed_review_store,
fed_remote_watchlist_repo, fed_remote_watchlist_repo,
fed_user_repo, fed_user_repo,
fed_profile_fields_repo,
fed_movie_repo, fed_movie_repo,
fed_review_repo, fed_review_repo,
fed_diary_repo, fed_diary_repo,