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

@@ -19,6 +19,7 @@ mod migrations;
mod models;
mod persons;
mod profile;
mod profile_fields;
mod users;
mod watchlist;
@@ -32,9 +33,16 @@ pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use users::SqliteUserRepository;
pub use watchlist::SqliteWatchlistRepository;
pub fn create_profile_fields_repo(
pool: sqlx::SqlitePool,
) -> std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository> {
std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool))
}
fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId,
};
pub struct SqliteProfileFieldsRepository {
pool: SqlitePool,
}
impl SqliteProfileFieldsRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
let id_str = user_id.value().to_string();
let 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()))?;
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 = ?", 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 (?, ?, ?, ?, ?)",
id, id_str, field.name, field.value, position
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}

View File

@@ -6,7 +6,7 @@ use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)"
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
)
.execute(&pool)
.await
@@ -61,6 +61,8 @@ async fn update_profile_persists_bio_and_avatar() {
user.id(),
Some("My biography".to_string()),
Some("avatars/user1".to_string()),
None,
None,
)
.await
.unwrap();
@@ -80,10 +82,10 @@ async fn update_profile_clears_fields_with_none() {
UserRole::Standard,
);
repo.save(&user).await.unwrap();
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()))
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None)
.await
.unwrap();
repo.update_profile(user.id(), None, None).await.unwrap();
repo.update_profile(user.id(), None, None, None, None).await.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None);

View File

@@ -39,6 +39,8 @@ impl SqliteUserRepository {
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()))?;
@@ -56,6 +58,8 @@ impl SqliteUserRepository {
role,
bio,
avatar_path,
banner_path,
also_known_as,
))
}
}
@@ -65,7 +69,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?",
email_str
)
.fetch_optional(&self.pool)
@@ -81,6 +85,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -89,7 +95,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let username_str = username.value();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
username_str
)
.fetch_optional(&self.pool)
@@ -105,6 +111,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -148,7 +156,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?",
id_str
)
.fetch_optional(&self.pool)
@@ -164,6 +172,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -174,15 +184,21 @@ impl UserRepository for SqliteUserRepository {
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 = ?, avatar_path = ? WHERE id = ?")
.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 = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
)
.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(())
}