feat: implement federated profile handling with support for remote actor URLs across multiple components
All checks were successful
CI / Check / Test (push) Successful in 1h7m53s

This commit is contained in:
2026-07-01 00:13:28 +02:00
parent d7fbf8d9e2
commit 9794babe06
25 changed files with 436 additions and 50 deletions

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::PostgresFederationRepository;
#[async_trait]
impl FederatedProfileQuery for PostgresFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = $1 AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = $1",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -2,6 +2,7 @@ mod activity;
mod actor; mod actor;
pub mod ap_content; pub mod ap_content;
mod blocklist; mod blocklist;
mod federated_profile;
mod follow; mod follow;
pub mod remote_goals; pub mod remote_goals;
mod review; mod review;
@@ -75,6 +76,12 @@ impl PostgresFederationRepository {
} }
} }
pub fn create_federated_profile_query(
pool: PgPool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(PostgresFederationRepository::new(pool))
}
pub fn wire(pool: PgPool) -> activitypub::FederationRepos { pub fn wire(pool: PgPool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool));
( (

View File

@@ -114,19 +114,24 @@ impl PostgresDiaryRepository {
&self, &self,
user_id: &str, user_id: &str,
search: Option<&str>, search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> { ) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search { let remote_clause = if include_remote {
"SELECT COUNT(*) FROM reviews r ""
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND m.title ILIKE '%' || $2 || '%'"
.to_string()
} else { } else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title ILIKE '%' || $2 || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r "SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1" WHERE r.user_id = $1{remote_clause}{search_clause}"
.to_string() );
};
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search { if has_search {
q = q.bind(search.unwrap()); q = q.bind(search.unwrap());
@@ -139,6 +144,7 @@ impl PostgresDiaryRepository {
user_id: &str, user_id: &str,
sort: &SortDirection, sort: &SortDirection,
search: Option<&str>, search: Option<&str>,
include_remote: bool,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
@@ -149,9 +155,13 @@ impl PostgresDiaryRepository {
SortDirection::Ascending => "r.watched_at ASC", SortDirection::Ascending => "r.watched_at ASC",
SortDirection::Descending => "r.watched_at DESC", SortDirection::Descending => "r.watched_at DESC",
}; };
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
// Build param counter: user_id=$1, optional search=$2, limit=$N-1, offset=$N let mut p: i32 = 1;
let mut p: i32 = 1; // $1 is user_id
let search_clause = if has_search { let search_clause = if has_search {
p += 1; p += 1;
format!(" AND m.title ILIKE '%' || ${} || '%'", p) format!(" AND m.title ILIKE '%' || ${} || '%'", p)
@@ -171,10 +181,9 @@ impl PostgresDiaryRepository {
r.remote_actor_url r.remote_actor_url
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = $1 AND r.remote_actor_url IS NULL{} WHERE r.user_id = $1{remote_clause}{search_clause}
ORDER BY {} ORDER BY {order_clause}
LIMIT {} OFFSET {}", LIMIT {limit_param} OFFSET {offset_param}",
search_clause, order_clause, limit_param, offset_param
); );
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
@@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository {
(None, Some(uid)) => { (None, Some(uid)) => {
let uid_str = uid.value().to_string(); let uid_str = uid.value().to_string();
let search = filter.search.as_deref(); let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!( tokio::try_join!(
self.count_user_diary_entries(&uid_str, search), self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset) self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)? )?
} }
(Some(_), Some(_)) => { (Some(_), Some(_)) => {

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use domain::{errors::DomainError, models::FederatedProfile, ports::FederatedProfileQuery};
use sqlx::Row;
use super::SqliteFederationRepository;
#[async_trait]
impl FederatedProfileQuery for SqliteFederationRepository {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError> {
let uid = synthetic_user_id.to_string();
let actor_url: Option<String> = sqlx::query_scalar(
"SELECT remote_actor_url FROM reviews
WHERE user_id = ? AND remote_actor_url IS NOT NULL
LIMIT 1",
)
.bind(&uid)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let actor_url = match actor_url {
Some(url) => url,
None => return Ok(None),
};
let row = sqlx::query(
"SELECT handle, display_name, bio, avatar_url, banner_url
FROM ap_remote_actors WHERE url = ?",
)
.bind(&actor_url)
.fetch_optional(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
match row {
Some(r) => Ok(Some(FederatedProfile {
actor_url,
handle: r.get("handle"),
display_name: r.try_get("display_name").ok().flatten(),
bio: r.try_get("bio").ok().flatten(),
avatar_url: r.try_get("avatar_url").ok().flatten(),
banner_url: r.try_get("banner_url").ok().flatten(),
})),
None => Ok(Some(FederatedProfile {
handle: actor_url.clone(),
actor_url,
display_name: None,
bio: None,
avatar_url: None,
banner_url: None,
})),
}
}
}

View File

@@ -1,13 +1,15 @@
mod activity; mod activity;
mod actor; mod actor;
pub mod ap_content;
mod blocklist; mod blocklist;
mod federated_profile;
mod follow; mod follow;
pub mod remote_goals;
mod review; mod review;
mod social; mod social;
mod watchlist; mod watchlist;
pub mod ap_content;
pub mod remote_goals;
pub use ap_content::SqliteApContentQuery; pub use ap_content::SqliteApContentQuery;
pub use remote_goals::SqliteRemoteGoalRepository; pub use remote_goals::SqliteRemoteGoalRepository;
@@ -86,6 +88,12 @@ impl SqliteFederationRepository {
} }
} }
pub fn create_federated_profile_query(
pool: SqlitePool,
) -> std::sync::Arc<dyn domain::ports::FederatedProfileQuery> {
std::sync::Arc::new(SqliteFederationRepository::new(pool))
}
pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos { pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos {
let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool));
( (

View File

@@ -108,19 +108,24 @@ impl SqliteDiaryRepository {
&self, &self,
user_id: &str, user_id: &str,
search: Option<&str>, search: Option<&str>,
include_remote: bool,
) -> Result<i64, DomainError> { ) -> Result<i64, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let sql = if has_search { let remote_clause = if include_remote {
"SELECT COUNT(*) FROM reviews r ""
INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND m.title LIKE '%' || ? || '%'"
.to_string()
} else { } else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'"
} else {
""
};
let sql = format!(
"SELECT COUNT(*) FROM reviews r "SELECT COUNT(*) FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ?" WHERE r.user_id = ?{remote_clause}{search_clause}"
.to_string() );
};
let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id);
if has_search { if has_search {
q = q.bind(search.unwrap()); q = q.bind(search.unwrap());
@@ -133,10 +138,16 @@ impl SqliteDiaryRepository {
user_id: &str, user_id: &str,
sort: &SortDirection, sort: &SortDirection,
search: Option<&str>, search: Option<&str>,
include_remote: bool,
limit: i64, limit: i64,
offset: i64, offset: i64,
) -> Result<Vec<DiaryRow>, DomainError> { ) -> Result<Vec<DiaryRow>, DomainError> {
let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); let has_search = search.map(|s| !s.is_empty()).unwrap_or(false);
let remote_clause = if include_remote {
""
} else {
" AND r.remote_actor_url IS NULL"
};
let search_clause = if has_search { let search_clause = if has_search {
" AND m.title LIKE '%' || ? || '%'" " AND m.title LIKE '%' || ? || '%'"
} else { } else {
@@ -153,10 +164,9 @@ impl SqliteDiaryRepository {
r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url r.id AS review_id, r.movie_id, r.user_id, r.rating, r.comment, r.watched_at, r.created_at, r.remote_actor_url
FROM reviews r FROM reviews r
INNER JOIN movies m ON m.id = r.movie_id INNER JOIN movies m ON m.id = r.movie_id
WHERE r.user_id = ? AND r.remote_actor_url IS NULL{} WHERE r.user_id = ?{remote_clause}{search_clause}
ORDER BY {} ORDER BY {order_clause}
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?",
search_clause, order_clause
); );
let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id);
if has_search { if has_search {
@@ -194,9 +204,17 @@ impl DiaryRepository for SqliteDiaryRepository {
(None, Some(uid)) => { (None, Some(uid)) => {
let uid_str = uid.value().to_string(); let uid_str = uid.value().to_string();
let search = filter.search.as_deref(); let search = filter.search.as_deref();
let inc = filter.include_remote;
tokio::try_join!( tokio::try_join!(
self.count_user_diary_entries(&uid_str, search), self.count_user_diary_entries(&uid_str, search, inc),
self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset) self.fetch_user_diary_rows(
&uid_str,
&filter.sort_by,
search,
inc,
limit,
offset
)
)? )?
} }
(Some(_), Some(_)) => { (Some(_), Some(_)) => {

View File

@@ -85,6 +85,16 @@ pub struct UserProfileResponse {
pub trends: Option<UserTrendsDto>, pub trends: Option<UserTrendsDto>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub goals: Option<Vec<GoalDto>>, pub goals: Option<Vec<GoalDto>>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub is_federated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actor_url: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]

View File

@@ -26,6 +26,7 @@ pub async fn execute(
movie_id, movie_id,
user_id, user_id,
search: None, search: None,
include_remote: false,
}; };
diary.query_diary(&filter).await diary.query_diary(&filter).await

View File

@@ -66,6 +66,7 @@ pub async fn execute(
query.limit, query.limit,
query.offset, query.offset,
query.search.clone(), query.search.clone(),
query.include_remote,
)?; )?;
let entries = deps.diary.query_diary(&filter).await?; let entries = deps.diary.query_diary(&filter).await?;
Ok(base(Some(entries), None, None)) Ok(base(Some(entries), None, None))
@@ -122,6 +123,7 @@ fn paged_user_filter(
limit: Option<u32>, limit: Option<u32>,
offset: Option<u32>, offset: Option<u32>,
search: Option<String>, search: Option<String>,
include_remote: bool,
) -> Result<DiaryFilter, DomainError> { ) -> Result<DiaryFilter, DomainError> {
let page = PageParams::new(limit, offset)?; let page = PageParams::new(limit, offset)?;
Ok(DiaryFilter { Ok(DiaryFilter {
@@ -130,6 +132,7 @@ fn paged_user_filter(
movie_id: None, movie_id: None,
user_id: Some(user_id), user_id: Some(user_id),
search, search,
include_remote,
}) })
} }
@@ -171,6 +174,7 @@ mod helper_tests {
Some(20), Some(20),
Some(5), Some(5),
Some("blade".into()), Some("blade".into()),
false,
) )
.unwrap(); .unwrap();

View File

@@ -43,6 +43,7 @@ pub struct GetUserProfileQuery {
pub sort_by: domain::models::FeedSortBy, pub sort_by: domain::models::FeedSortBy,
pub search: Option<String>, pub search: Option<String>,
pub is_own_profile: bool, pub is_own_profile: bool,
pub include_remote: bool,
} }
pub struct GetCurrentProfileQuery { pub struct GetCurrentProfileQuery {

View File

@@ -54,6 +54,7 @@ async fn returns_profile_with_empty_stats() {
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: None, search: None,
is_own_profile: true, is_own_profile: true,
include_remote: false,
}, },
) )
.await .await
@@ -88,6 +89,7 @@ async fn returns_history_view() {
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: None, search: None,
is_own_profile: true, is_own_profile: true,
include_remote: false,
}, },
) )
.await .await
@@ -124,6 +126,7 @@ async fn returns_trends_view() {
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: None, search: None,
is_own_profile: true, is_own_profile: true,
include_remote: false,
}, },
) )
.await .await
@@ -160,6 +163,7 @@ async fn returns_ratings_view() {
sort_by: domain::models::FeedSortBy::Rating, sort_by: domain::models::FeedSortBy::Rating,
search: None, search: None,
is_own_profile: true, is_own_profile: true,
include_remote: false,
}, },
) )
.await .await
@@ -194,6 +198,7 @@ async fn returns_recent_with_search() {
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: Some("blade".into()), search: Some("blade".into()),
is_own_profile: true, is_own_profile: true,
include_remote: false,
}, },
) )
.await .await
@@ -228,6 +233,7 @@ async fn non_own_profile_skips_pending_followers() {
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: None, search: None,
is_own_profile: false, is_own_profile: false,
include_remote: false,
}, },
) )
.await .await

View File

@@ -18,3 +18,13 @@ pub struct FederationFlags {
pub reviews: bool, pub reviews: bool,
pub watchlist: bool, pub watchlist: bool,
} }
#[derive(Debug, Clone)]
pub struct FederatedProfile {
pub actor_url: String,
pub handle: String,
pub display_name: Option<String>,
pub bio: Option<String>,
pub avatar_url: Option<String>,
pub banner_url: Option<String>,
}

View File

@@ -133,6 +133,7 @@ pub struct DiaryFilter {
pub movie_id: Option<MovieId>, pub movie_id: Option<MovieId>,
pub user_id: Option<UserId>, pub user_id: Option<UserId>,
pub search: Option<String>, pub search: Option<String>,
pub include_remote: bool,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -0,0 +1,11 @@
use async_trait::async_trait;
use crate::{errors::DomainError, models::FederatedProfile};
#[async_trait]
pub trait FederatedProfileQuery: Send + Sync {
async fn get_federated_profile(
&self,
synthetic_user_id: uuid::Uuid,
) -> Result<Option<FederatedProfile>, DomainError>;
}

View File

@@ -1,6 +1,7 @@
pub mod auth; pub mod auth;
pub mod diary; pub mod diary;
pub mod events; pub mod events;
pub mod federated_profile;
pub mod goals; pub mod goals;
pub mod images; pub mod images;
pub mod import; pub mod import;
@@ -16,6 +17,7 @@ pub mod wrapup;
pub use auth::*; pub use auth::*;
pub use diary::*; pub use diary::*;
pub use events::*; pub use events::*;
pub use federated_profile::*;
pub use goals::*; pub use goals::*;
pub use images::*; pub use images::*;
pub use import::*; pub use import::*;

View File

@@ -347,6 +347,18 @@ impl crate::ports::SocialQueryPort for PanicSocialQueryPort {
} }
} }
pub struct PanicFederatedProfileQuery;
#[async_trait]
impl crate::ports::FederatedProfileQuery for PanicFederatedProfileQuery {
async fn get_federated_profile(
&self,
_: uuid::Uuid,
) -> Result<Option<crate::models::FederatedProfile>, DomainError> {
panic!("PanicFederatedProfileQuery called")
}
}
pub struct PanicWatchEventRepository; pub struct PanicWatchEventRepository;
#[async_trait] #[async_trait]

View File

@@ -1,14 +1,14 @@
use std::sync::Arc; use std::sync::Arc;
use domain::ports::{ use domain::ports::{
AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository, AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher,
ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, FederatedProfileQuery, GoalRepository, ImportProfileRepository, ImportSessionRepository,
MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient, MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher,
PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository, PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient,
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository,
StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository,
WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository, UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository,
WrapUpStatsQuery, WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery,
}; };
use application::config::AppConfig; use application::config::AppConfig;
@@ -40,6 +40,7 @@ pub struct Repositories {
pub user_settings: Arc<dyn UserSettingsRepository>, pub user_settings: Arc<dyn UserSettingsRepository>,
pub remote_goal: Arc<dyn RemoteGoalRepository>, pub remote_goal: Arc<dyn RemoteGoalRepository>,
pub refresh_session: Arc<dyn RefreshSessionRepository>, pub refresh_session: Arc<dyn RefreshSessionRepository>,
pub federated_profile: Option<Arc<dyn FederatedProfileQuery>>,
} }
#[derive(Clone)] #[derive(Clone)]

View File

@@ -265,20 +265,30 @@ pub async fn get_user_profile(
Err(_) => return StatusCode::BAD_REQUEST.into_response(), Err(_) => return StatusCode::BAD_REQUEST.into_response(),
}; };
let user = match state let local_user = match state
.app_ctx .app_ctx
.repos .repos
.user .user
.find_by_id(&UserId::from_uuid(user_id)) .find_by_id(&UserId::from_uuid(user_id))
.await .await
{ {
Ok(Some(u)) => u, Ok(u) => u,
Ok(None) => return StatusCode::NOT_FOUND.into_response(),
Err(e) => { Err(e) => {
return crate::errors::domain_error_response(e); return crate::errors::domain_error_response(e);
} }
}; };
if local_user.is_none() {
if let Some(ref fed_query) = state.app_ctx.repos.federated_profile
&& let Ok(Some(fed)) = fed_query.get_federated_profile(user_id).await
{
return build_federated_profile_response(&state, user_id, fed, profile_view, &params)
.await;
}
return StatusCode::NOT_FOUND.into_response();
}
let user = local_user.unwrap();
let get_profile_deps = GetProfileDeps { let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(), stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(), diary: state.app_ctx.repos.diary.clone(),
@@ -294,6 +304,7 @@ pub async fn get_user_profile(
sort_by: domain::models::FeedSortBy::Date, sort_by: domain::models::FeedSortBy::Date,
search: params.search, search: params.search,
is_own_profile: viewer_id.value() == user_id, is_own_profile: viewer_id.value() == user_id,
include_remote: false,
}, },
) )
.await .await
@@ -384,6 +395,106 @@ pub async fn get_user_profile(
Some(goals_list.iter().map(goal_with_progress_to_dto).collect()) Some(goals_list.iter().map(goal_with_progress_to_dto).collect())
} }
}, },
is_federated: false,
handle: None,
display_name: None,
bio: None,
actor_url: None,
})
.into_response()
}
async fn build_federated_profile_response(
state: &AppState,
user_id: Uuid,
fed: domain::models::FederatedProfile,
profile_view: application::users::queries::ProfileView,
params: &UserProfileQueryParams,
) -> axum::response::Response {
let get_profile_deps = GetProfileDeps {
stats: state.app_ctx.repos.stats.clone(),
diary: state.app_ctx.repos.diary.clone(),
social_query: state.app_ctx.repos.social_query.clone(),
};
let profile = match get_user_profile_uc::execute(
&get_profile_deps,
GetUserProfileQuery {
user_id,
view: profile_view,
limit: params.limit,
offset: params.offset,
sort_by: domain::models::FeedSortBy::Date,
search: params.search.clone(),
is_own_profile: false,
include_remote: true,
},
)
.await
{
Ok(p) => p,
Err(e) => return crate::errors::domain_error_response(e),
};
let entries = profile.entries.map(|p| DiaryResponse {
items: p
.items
.iter()
.map(crate::mappers::movies::entry_to_dto)
.collect(),
total_count: p.total_count,
limit: p.limit,
offset: p.offset,
});
let trends = profile.trends.map(|t| UserTrendsDto {
monthly_ratings: t
.monthly_ratings
.into_iter()
.map(|r| MonthlyRatingDto {
year_month: r.year_month,
month_label: r.month_label,
avg_rating: r.avg_rating,
count: r.count,
})
.collect(),
top_directors: t
.top_directors
.into_iter()
.map(|d| DirectorStatDto {
director: d.director,
count: d.count,
})
.collect(),
max_director_count: t.max_director_count,
});
let username = fed
.display_name
.clone()
.unwrap_or_else(|| fed.handle.clone());
Json(UserProfileResponse {
user_id,
username,
avatar_url: fed.avatar_url,
banner_url: fed.banner_url,
stats: UserStatsDto {
total_movies: profile.stats.total_movies,
avg_rating: profile.stats.avg_rating,
favorite_director: profile.stats.favorite_director,
most_active_month: profile.stats.most_active_month,
},
following_count: 0,
followers_count: 0,
entries,
history: None,
trends,
goals: None,
is_federated: true,
handle: Some(fed.handle),
display_name: fed.display_name,
bio: fed.bio,
actor_url: Some(fed.actor_url),
}) })
.into_response() .into_response()
} }
@@ -538,6 +649,7 @@ pub async fn get_user_profile_html(
Some(params.search.clone()) Some(params.search.clone())
}, },
is_own_profile, is_own_profile,
include_remote: false,
}; };
let html_profile_deps = GetProfileDeps { let html_profile_deps = GetProfileDeps {

View File

@@ -207,6 +207,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
user_settings: db.user_settings, user_settings: db.user_settings,
remote_goal: db.remote_goal, remote_goal: db.remote_goal,
refresh_session: db.refresh_session, refresh_session: db.refresh_session,
#[cfg(feature = "federation")]
federated_profile: Some({
match &db_pool {
#[cfg(feature = "sqlite-federation")]
factory::DbPool::Sqlite(pool) => {
sqlite_federation::create_federated_profile_query(pool.clone())
}
#[cfg(feature = "postgres-federation")]
factory::DbPool::Postgres(pool) => {
postgres_federation::create_federated_profile_query(pool.clone())
}
#[cfg(not(feature = "sqlite-federation"))]
_ => unreachable!(),
}
}),
#[cfg(not(feature = "federation"))]
federated_profile: None,
}, },
services: Services { services: Services {
auth: auth_service, auth: auth_service,

View File

@@ -807,6 +807,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
user_settings: Arc::clone(&repo) as _, user_settings: Arc::clone(&repo) as _,
remote_goal: Arc::clone(&repo) as _, remote_goal: Arc::clone(&repo) as _,
refresh_session: Arc::clone(&repo) as _, refresh_session: Arc::clone(&repo) as _,
federated_profile: None,
}, },
services: Services { services: Services {
auth: auth_service, auth: auth_service,

View File

@@ -460,6 +460,7 @@ async fn test_app() -> Router {
user_settings: Arc::new(domain::testing::NoopUserSettingsRepository), user_settings: Arc::new(domain::testing::NoopUserSettingsRepository),
remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository), remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository),
refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository), refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository),
federated_profile: None,
}, },
services: Services { services: Services {
auth: Arc::new(PanicAuth), auth: Arc::new(PanicAuth),

View File

@@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router"
import { useCallback } from "react" import { useCallback } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { Bar, BarChart, XAxis, YAxis } from "recharts" import { Bar, BarChart, XAxis, YAxis } from "recharts"
import { Search, User } from "lucide-react" import { Globe, Search, User } from "lucide-react"
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
@@ -23,6 +23,10 @@ type ProfileViewProps = {
userId?: string userId?: string
search?: string search?: string
onSearchChange?: (value: string) => void onSearchChange?: (value: string) => void
isFederated?: boolean
bio?: string
handle?: string
actorUrl?: string
} }
export function ProfileView({ export function ProfileView({
@@ -32,6 +36,9 @@ export function ProfileView({
userId, userId,
search, search,
onSearchChange, onSearchChange,
isFederated,
bio,
handle,
}: ProfileViewProps) { }: ProfileViewProps) {
const { t } = useTranslation() const { t } = useTranslation()
const initial = (data.username || "?")[0]?.toUpperCase() ?? "?" const initial = (data.username || "?")[0]?.toUpperCase() ?? "?"
@@ -50,8 +57,15 @@ export function ProfileView({
{avatar && <AvatarImage src={avatar} />} {avatar && <AvatarImage src={avatar} />}
<AvatarFallback>{initial}</AvatarFallback> <AvatarFallback>{initial}</AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1"> <div className="min-w-0 flex-1">
<p className="font-semibold">{data.username}</p> <p className="font-semibold">{data.username}</p>
{isFederated && handle && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Globe className="size-3" />
<span>{handle}</span>
</div>
)}
{bio && <p className="mt-1 text-sm text-muted-foreground">{bio}</p>}
</div> </div>
{headerRight} {headerRight}
</div> </div>

View File

@@ -86,6 +86,11 @@ export const userProfileResponseSchema = z.object({
history: z.array(monthActivityDtoSchema).optional(), history: z.array(monthActivityDtoSchema).optional(),
trends: userTrendsDtoSchema.optional(), trends: userTrendsDtoSchema.optional(),
goals: z.array(goalDtoSchema).optional(), goals: z.array(goalDtoSchema).optional(),
is_federated: z.boolean().optional().default(false),
handle: z.string().optional(),
display_name: z.string().optional(),
bio: z.string().optional(),
actor_url: z.string().optional(),
}) })
export type UserProfileResponse = z.infer<typeof userProfileResponseSchema> export type UserProfileResponse = z.infer<typeof userProfileResponseSchema>

View File

@@ -115,7 +115,7 @@ function FeedTab() {
movie={entry.movie} movie={entry.movie}
review={entry.review} review={entry.review}
userName={entry.user_display_name} userName={entry.user_display_name}
userId={entry.is_federated ? undefined : entry.user_id} userId={entry.user_id}
isFederated={entry.is_federated} isFederated={entry.is_federated}
actorUrl={entry.actor_url} actorUrl={entry.actor_url}
/> />

View File

@@ -1,7 +1,7 @@
import { createFileRoute } from "@tanstack/react-router" import { createFileRoute } from "@tanstack/react-router"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
import { UserCheck, UserPlus } from "lucide-react" import { ExternalLink, UserCheck, UserPlus } from "lucide-react"
import { BackButton } from "@/components/back-button" import { BackButton } from "@/components/back-button"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ProfileView, ProfileSkeleton } from "@/components/profile-view" import { ProfileView, ProfileSkeleton } from "@/components/profile-view"
@@ -39,10 +39,14 @@ function UserProfilePage() {
<ProfileView <ProfileView
data={data} data={data}
userId={id} userId={id}
search={search} search={data.is_federated ? undefined : search}
onSearchChange={setSearch} onSearchChange={data.is_federated ? undefined : setSearch}
isFederated={data.is_federated}
bio={data.bio}
handle={data.handle}
actorUrl={data.actor_url}
actions={ actions={
data.goals?.length ? ( !data.is_federated && data.goals?.length ? (
<div className="space-y-2"> <div className="space-y-2">
{data.goals.map((g) => ( {data.goals.map((g) => (
<GoalCard key={g.year} goal={g} /> <GoalCard key={g.year} goal={g} />
@@ -51,7 +55,7 @@ function UserProfilePage() {
) : undefined ) : undefined
} }
headerRight={ headerRight={
!isSelf ? ( !isSelf && !data.is_federated ? (
isFollowing ? ( isFollowing ? (
<Button <Button
size="sm" size="sm"
@@ -72,6 +76,13 @@ function UserProfilePage() {
{t("common.follow")} {t("common.follow")}
</Button> </Button>
) )
) : data.is_federated && data.actor_url ? (
<a href={data.actor_url} target="_blank" rel="noopener noreferrer">
<Button size="sm" variant="outline">
<ExternalLink className="mr-1 size-3.5" />
{t("common.viewOnRemote", { defaultValue: "Remote" })}
</Button>
</a>
) : undefined ) : undefined
} }
/> />