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:
@@ -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 {
|
||||
|
||||
58
crates/adapters/sqlite/src/profile_fields.rs
Normal file
58
crates/adapters/sqlite/src/profile_fields.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user