feat(ap): ActivityPub spec compliance and profile completeness

Phase 1 — spec compliance:
- Add AS_PUBLIC constant; add to/cc fields to CreateActivity, DeleteActivity,
  UpdateActivity, AddActivity; populate on all broadcast call sites
- Add @context to outbox CreateActivity items
- Set manuallyApprovesFollowers: true to match actual Pending follow flow
- Gate PermissiveVerifier behind FEDERATION_DEBUG env var
- Add updated timestamp to Person actor JSON
- Improve actor update delivery logging

Phase 2a Batch 1 — AP layer:
- Add /inbox shared inbox route; add endpoints.sharedInbox to Person
- Paginate followers and following collections (20/page, OrderedCollectionPage)

Phase 2a Batch 2 — profile completeness:
- DB migrations: banner_path, also_known_as columns; user_profile_fields table
- ProfileField value object; UserProfileFieldsRepository port
- Banner image upload (stored via image-converter, surfaced as image in Person)
- alsoKnownAs field in Person (account migration support)
- Custom profile fields (up to 4 PropertyValue attachments in Person)
- Profile settings UI: banner preview/upload, alsoKnownAs input, fields form
- PUT /api/v1/profile/fields API endpoint
This commit is contained in:
2026-05-13 22:21:41 +02:00
parent 0a97fe5544
commit 815178e6a4
56 changed files with 1388 additions and 246 deletions

View File

@@ -18,6 +18,7 @@ mod import_session;
mod models;
mod persons;
mod profile;
mod profile_fields;
mod users;
mod watchlist;
@@ -31,6 +32,7 @@ pub use import_profile::PostgresImportProfileRepository;
pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use profile_fields::PostgresProfileFieldsRepository;
pub use users::PostgresUserRepository;
pub use watchlist::PostgresWatchlistRepository;
@@ -931,6 +933,12 @@ impl StatsRepository for PostgresRepository {
}
}
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 async fn wire(database_url: &str) -> anyhow::Result<(
sqlx::PgPool,
std::sync::Arc<dyn domain::ports::MovieRepository>,

View File

@@ -0,0 +1,76 @@
use async_trait::async_trait;
use sqlx::PgPool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId,
};
pub struct PostgresProfileFieldsRepository {
pool: PgPool,
}
impl PostgresProfileFieldsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserProfileFieldsRepository for PostgresProfileFieldsRepository {
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
let id_str = user_id.value().to_string();
#[derive(sqlx::FromRow)]
struct Row {
name: String,
value: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| ProfileField {
name: r.name,
value: r.value,
})
.collect())
}
async fn set_fields(
&self,
user_id: &UserId,
fields: Vec<ProfileField>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = $1")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for (i, field) in fields.into_iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string();
let position = i as i64;
sqlx::query(
"INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES ($1, $2, $3, $4, $5)",
)
.bind(&id)
.bind(&id_str)
.bind(&field.name)
.bind(&field.value)
.bind(position)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}

View File

@@ -40,6 +40,8 @@ impl PostgresUserRepository {
role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -57,6 +59,8 @@ impl PostgresUserRepository {
role,
bio,
avatar_path,
banner_path,
also_known_as,
))
}
}
@@ -74,9 +78,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = $1",
)
.bind(email_str)
.fetch_optional(&self.pool)
@@ -91,6 +97,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -107,9 +115,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = $1",
)
.bind(username_str)
.fetch_optional(&self.pool)
@@ -124,6 +134,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -178,9 +190,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
@@ -195,6 +209,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -205,15 +221,21 @@ impl UserRepository for PostgresUserRepository {
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3")
.bind(&bio)
.bind(&avatar_path)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query(
"UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5",
)
.bind(&bio)
.bind(&avatar_path)
.bind(&banner_path)
.bind(&also_known_as)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}