From 9794babe06993f8139c7ba9e3630585a1adb7541 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 1 Jul 2026 00:13:28 +0200 Subject: [PATCH] feat: implement federated profile handling with support for remote actor URLs across multiple components --- .../src/federated_profile.rs | 58 +++++++++ .../adapters/postgres-federation/src/lib.rs | 7 ++ crates/adapters/postgres/src/diary.rs | 49 +++++--- .../src/federated_profile.rs | 58 +++++++++ crates/adapters/sqlite-federation/src/lib.rs | 12 +- crates/adapters/sqlite/src/diary.rs | 44 +++++-- crates/api-types/src/users.rs | 10 ++ crates/application/src/diary/get_diary.rs | 1 + crates/application/src/users/get_profile.rs | 4 + crates/application/src/users/queries.rs | 1 + .../src/users/tests/get_profile.rs | 6 + crates/domain/src/models/federation.rs | 10 ++ crates/domain/src/models/review.rs | 1 + crates/domain/src/ports/federated_profile.rs | 11 ++ crates/domain/src/ports/mod.rs | 2 + crates/domain/src/testing/panics.rs | 12 ++ crates/presentation/src/context.rs | 17 +-- crates/presentation/src/handlers/users.rs | 118 +++++++++++++++++- crates/presentation/src/main.rs | 17 +++ crates/presentation/src/tests/extractors.rs | 1 + crates/presentation/tests/api_test.rs | 1 + spa/src/components/profile-view.tsx | 18 ++- spa/src/lib/api/users.ts | 5 + spa/src/routes/_app/index.tsx | 2 +- spa/src/routes/_app/users.$id.tsx | 21 +++- 25 files changed, 436 insertions(+), 50 deletions(-) create mode 100644 crates/adapters/postgres-federation/src/federated_profile.rs create mode 100644 crates/adapters/sqlite-federation/src/federated_profile.rs create mode 100644 crates/domain/src/ports/federated_profile.rs diff --git a/crates/adapters/postgres-federation/src/federated_profile.rs b/crates/adapters/postgres-federation/src/federated_profile.rs new file mode 100644 index 0000000..3e12da8 --- /dev/null +++ b/crates/adapters/postgres-federation/src/federated_profile.rs @@ -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, DomainError> { + let uid = synthetic_user_id.to_string(); + + let actor_url: Option = 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, + })), + } + } +} diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index c3be1f0..4c49a28 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -2,6 +2,7 @@ mod activity; mod actor; pub mod ap_content; mod blocklist; +mod federated_profile; mod follow; pub mod remote_goals; mod review; @@ -75,6 +76,12 @@ impl PostgresFederationRepository { } } +pub fn create_federated_profile_query( + pool: PgPool, +) -> std::sync::Arc { + std::sync::Arc::new(PostgresFederationRepository::new(pool)) +} + pub fn wire(pool: PgPool) -> activitypub::FederationRepos { let fed = std::sync::Arc::new(PostgresFederationRepository::new(pool)); ( diff --git a/crates/adapters/postgres/src/diary.rs b/crates/adapters/postgres/src/diary.rs index 1f950fc..89f6b60 100644 --- a/crates/adapters/postgres/src/diary.rs +++ b/crates/adapters/postgres/src/diary.rs @@ -114,19 +114,24 @@ impl PostgresDiaryRepository { &self, user_id: &str, search: Option<&str>, + include_remote: bool, ) -> Result { let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let sql = if has_search { - "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() + let remote_clause = if include_remote { + "" } 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 INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1" - .to_string() - }; + WHERE r.user_id = $1{remote_clause}{search_clause}" + ); let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); if has_search { q = q.bind(search.unwrap()); @@ -139,6 +144,7 @@ impl PostgresDiaryRepository { user_id: &str, sort: &SortDirection, search: Option<&str>, + include_remote: bool, limit: i64, offset: i64, ) -> Result, DomainError> { @@ -149,9 +155,13 @@ impl PostgresDiaryRepository { SortDirection::Ascending => "r.watched_at ASC", 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; // $1 is user_id + let mut p: i32 = 1; let search_clause = if has_search { p += 1; format!(" AND m.title ILIKE '%' || ${} || '%'", p) @@ -171,10 +181,9 @@ impl PostgresDiaryRepository { r.remote_actor_url FROM reviews r INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = $1 AND r.remote_actor_url IS NULL{} - ORDER BY {} - LIMIT {} OFFSET {}", - search_clause, order_clause, limit_param, offset_param + WHERE r.user_id = $1{remote_clause}{search_clause} + ORDER BY {order_clause} + LIMIT {limit_param} OFFSET {offset_param}", ); let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); @@ -213,9 +222,17 @@ impl DiaryRepository for PostgresDiaryRepository { (None, Some(uid)) => { let uid_str = uid.value().to_string(); let search = filter.search.as_deref(); + let inc = filter.include_remote; tokio::try_join!( - self.count_user_diary_entries(&uid_str, search), - self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset) + self.count_user_diary_entries(&uid_str, search, inc), + self.fetch_user_diary_rows( + &uid_str, + &filter.sort_by, + search, + inc, + limit, + offset + ) )? } (Some(_), Some(_)) => { diff --git a/crates/adapters/sqlite-federation/src/federated_profile.rs b/crates/adapters/sqlite-federation/src/federated_profile.rs new file mode 100644 index 0000000..d53eb97 --- /dev/null +++ b/crates/adapters/sqlite-federation/src/federated_profile.rs @@ -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, DomainError> { + let uid = synthetic_user_id.to_string(); + + let actor_url: Option = 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, + })), + } + } +} diff --git a/crates/adapters/sqlite-federation/src/lib.rs b/crates/adapters/sqlite-federation/src/lib.rs index 1158b53..65110f2 100644 --- a/crates/adapters/sqlite-federation/src/lib.rs +++ b/crates/adapters/sqlite-federation/src/lib.rs @@ -1,13 +1,15 @@ mod activity; mod actor; -pub mod ap_content; mod blocklist; +mod federated_profile; mod follow; -pub mod remote_goals; mod review; mod social; mod watchlist; +pub mod ap_content; +pub mod remote_goals; + pub use ap_content::SqliteApContentQuery; pub use remote_goals::SqliteRemoteGoalRepository; @@ -86,6 +88,12 @@ impl SqliteFederationRepository { } } +pub fn create_federated_profile_query( + pool: SqlitePool, +) -> std::sync::Arc { + std::sync::Arc::new(SqliteFederationRepository::new(pool)) +} + pub fn wire(pool: SqlitePool) -> activitypub::FederationRepos { let fed = std::sync::Arc::new(SqliteFederationRepository::new(pool)); ( diff --git a/crates/adapters/sqlite/src/diary.rs b/crates/adapters/sqlite/src/diary.rs index 76aeee9..e9407b8 100644 --- a/crates/adapters/sqlite/src/diary.rs +++ b/crates/adapters/sqlite/src/diary.rs @@ -108,19 +108,24 @@ impl SqliteDiaryRepository { &self, user_id: &str, search: Option<&str>, + include_remote: bool, ) -> Result { let has_search = search.map(|s| !s.is_empty()).unwrap_or(false); - let sql = if has_search { - "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() + let remote_clause = if include_remote { + "" } 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 INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ?" - .to_string() - }; + WHERE r.user_id = ?{remote_clause}{search_clause}" + ); let mut q = sqlx::query_scalar::<_, i64>(&sql).bind(user_id); if has_search { q = q.bind(search.unwrap()); @@ -133,10 +138,16 @@ impl SqliteDiaryRepository { user_id: &str, sort: &SortDirection, search: Option<&str>, + include_remote: bool, limit: i64, offset: i64, ) -> Result, DomainError> { 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 { " AND m.title LIKE '%' || ? || '%'" } 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 FROM reviews r INNER JOIN movies m ON m.id = r.movie_id - WHERE r.user_id = ? AND r.remote_actor_url IS NULL{} - ORDER BY {} + WHERE r.user_id = ?{remote_clause}{search_clause} + ORDER BY {order_clause} LIMIT ? OFFSET ?", - search_clause, order_clause ); let mut q = sqlx::query_as::<_, DiaryRow>(&sql).bind(user_id); if has_search { @@ -194,9 +204,17 @@ impl DiaryRepository for SqliteDiaryRepository { (None, Some(uid)) => { let uid_str = uid.value().to_string(); let search = filter.search.as_deref(); + let inc = filter.include_remote; tokio::try_join!( - self.count_user_diary_entries(&uid_str, search), - self.fetch_user_diary_rows(&uid_str, &filter.sort_by, search, limit, offset) + self.count_user_diary_entries(&uid_str, search, inc), + self.fetch_user_diary_rows( + &uid_str, + &filter.sort_by, + search, + inc, + limit, + offset + ) )? } (Some(_), Some(_)) => { diff --git a/crates/api-types/src/users.rs b/crates/api-types/src/users.rs index 71d314e..74d5dba 100644 --- a/crates/api-types/src/users.rs +++ b/crates/api-types/src/users.rs @@ -85,6 +85,16 @@ pub struct UserProfileResponse { pub trends: Option, #[serde(skip_serializing_if = "Option::is_none")] pub goals: Option>, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_federated: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bio: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor_url: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] diff --git a/crates/application/src/diary/get_diary.rs b/crates/application/src/diary/get_diary.rs index b877aed..2a57500 100644 --- a/crates/application/src/diary/get_diary.rs +++ b/crates/application/src/diary/get_diary.rs @@ -26,6 +26,7 @@ pub async fn execute( movie_id, user_id, search: None, + include_remote: false, }; diary.query_diary(&filter).await diff --git a/crates/application/src/users/get_profile.rs b/crates/application/src/users/get_profile.rs index 6d31aae..221651c 100644 --- a/crates/application/src/users/get_profile.rs +++ b/crates/application/src/users/get_profile.rs @@ -66,6 +66,7 @@ pub async fn execute( query.limit, query.offset, query.search.clone(), + query.include_remote, )?; let entries = deps.diary.query_diary(&filter).await?; Ok(base(Some(entries), None, None)) @@ -122,6 +123,7 @@ fn paged_user_filter( limit: Option, offset: Option, search: Option, + include_remote: bool, ) -> Result { let page = PageParams::new(limit, offset)?; Ok(DiaryFilter { @@ -130,6 +132,7 @@ fn paged_user_filter( movie_id: None, user_id: Some(user_id), search, + include_remote, }) } @@ -171,6 +174,7 @@ mod helper_tests { Some(20), Some(5), Some("blade".into()), + false, ) .unwrap(); diff --git a/crates/application/src/users/queries.rs b/crates/application/src/users/queries.rs index 610d79d..c3e66b6 100644 --- a/crates/application/src/users/queries.rs +++ b/crates/application/src/users/queries.rs @@ -43,6 +43,7 @@ pub struct GetUserProfileQuery { pub sort_by: domain::models::FeedSortBy, pub search: Option, pub is_own_profile: bool, + pub include_remote: bool, } pub struct GetCurrentProfileQuery { diff --git a/crates/application/src/users/tests/get_profile.rs b/crates/application/src/users/tests/get_profile.rs index 9936c38..a5ee7b9 100644 --- a/crates/application/src/users/tests/get_profile.rs +++ b/crates/application/src/users/tests/get_profile.rs @@ -54,6 +54,7 @@ async fn returns_profile_with_empty_stats() { sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, + include_remote: false, }, ) .await @@ -88,6 +89,7 @@ async fn returns_history_view() { sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, + include_remote: false, }, ) .await @@ -124,6 +126,7 @@ async fn returns_trends_view() { sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: true, + include_remote: false, }, ) .await @@ -160,6 +163,7 @@ async fn returns_ratings_view() { sort_by: domain::models::FeedSortBy::Rating, search: None, is_own_profile: true, + include_remote: false, }, ) .await @@ -194,6 +198,7 @@ async fn returns_recent_with_search() { sort_by: domain::models::FeedSortBy::Date, search: Some("blade".into()), is_own_profile: true, + include_remote: false, }, ) .await @@ -228,6 +233,7 @@ async fn non_own_profile_skips_pending_followers() { sort_by: domain::models::FeedSortBy::Date, search: None, is_own_profile: false, + include_remote: false, }, ) .await diff --git a/crates/domain/src/models/federation.rs b/crates/domain/src/models/federation.rs index ae2f13e..84796fb 100644 --- a/crates/domain/src/models/federation.rs +++ b/crates/domain/src/models/federation.rs @@ -18,3 +18,13 @@ pub struct FederationFlags { pub reviews: bool, pub watchlist: bool, } + +#[derive(Debug, Clone)] +pub struct FederatedProfile { + pub actor_url: String, + pub handle: String, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub banner_url: Option, +} diff --git a/crates/domain/src/models/review.rs b/crates/domain/src/models/review.rs index 4cafcbc..a308faa 100644 --- a/crates/domain/src/models/review.rs +++ b/crates/domain/src/models/review.rs @@ -133,6 +133,7 @@ pub struct DiaryFilter { pub movie_id: Option, pub user_id: Option, pub search: Option, + pub include_remote: bool, } #[derive(Clone, Debug)] diff --git a/crates/domain/src/ports/federated_profile.rs b/crates/domain/src/ports/federated_profile.rs new file mode 100644 index 0000000..8b876f4 --- /dev/null +++ b/crates/domain/src/ports/federated_profile.rs @@ -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, DomainError>; +} diff --git a/crates/domain/src/ports/mod.rs b/crates/domain/src/ports/mod.rs index 1d3d6c8..9e687f9 100644 --- a/crates/domain/src/ports/mod.rs +++ b/crates/domain/src/ports/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod diary; pub mod events; +pub mod federated_profile; pub mod goals; pub mod images; pub mod import; @@ -16,6 +17,7 @@ pub mod wrapup; pub use auth::*; pub use diary::*; pub use events::*; +pub use federated_profile::*; pub use goals::*; pub use images::*; pub use import::*; diff --git a/crates/domain/src/testing/panics.rs b/crates/domain/src/testing/panics.rs index 3ddf400..2a304ed 100644 --- a/crates/domain/src/testing/panics.rs +++ b/crates/domain/src/testing/panics.rs @@ -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, DomainError> { + panic!("PanicFederatedProfileQuery called") + } +} + pub struct PanicWatchEventRepository; #[async_trait] diff --git a/crates/presentation/src/context.rs b/crates/presentation/src/context.rs index 6d5cac0..98b67a7 100644 --- a/crates/presentation/src/context.rs +++ b/crates/presentation/src/context.rs @@ -1,14 +1,14 @@ use std::sync::Arc; use domain::ports::{ - AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, GoalRepository, - ImportProfileRepository, ImportSessionRepository, MetadataClient, MovieProfileRepository, - MovieRepository, ObjectStorage, PasswordHasher, PersonCommand, PersonEnrichmentClient, - PersonQuery, PosterFetcherClient, RefreshSessionRepository, RemoteGoalRepository, - RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, - StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, - WatchEventRepository, WatchlistRepository, WebhookTokenRepository, WrapUpRepository, - WrapUpStatsQuery, + AuthService, DiaryExporter, DiaryRepository, DocumentParser, EventPublisher, + FederatedProfileQuery, GoalRepository, ImportProfileRepository, ImportSessionRepository, + MetadataClient, MovieProfileRepository, MovieRepository, ObjectStorage, PasswordHasher, + PersonCommand, PersonEnrichmentClient, PersonQuery, PosterFetcherClient, + RefreshSessionRepository, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, + SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository, + UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, + WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, }; use application::config::AppConfig; @@ -40,6 +40,7 @@ pub struct Repositories { pub user_settings: Arc, pub remote_goal: Arc, pub refresh_session: Arc, + pub federated_profile: Option>, } #[derive(Clone)] diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index ee8b2e2..2471225 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -265,20 +265,30 @@ pub async fn get_user_profile( Err(_) => return StatusCode::BAD_REQUEST.into_response(), }; - let user = match state + let local_user = match state .app_ctx .repos .user .find_by_id(&UserId::from_uuid(user_id)) .await { - Ok(Some(u)) => u, - Ok(None) => return StatusCode::NOT_FOUND.into_response(), + Ok(u) => u, Err(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, ¶ms) + .await; + } + return StatusCode::NOT_FOUND.into_response(); + } + let user = local_user.unwrap(); + let get_profile_deps = GetProfileDeps { stats: state.app_ctx.repos.stats.clone(), diary: state.app_ctx.repos.diary.clone(), @@ -294,6 +304,7 @@ pub async fn get_user_profile( sort_by: domain::models::FeedSortBy::Date, search: params.search, is_own_profile: viewer_id.value() == user_id, + include_remote: false, }, ) .await @@ -384,6 +395,106 @@ pub async fn get_user_profile( 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() } @@ -538,6 +649,7 @@ pub async fn get_user_profile_html( Some(params.search.clone()) }, is_own_profile, + include_remote: false, }; let html_profile_deps = GetProfileDeps { diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index d07a5ad..fff379e 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -207,6 +207,23 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { user_settings: db.user_settings, remote_goal: db.remote_goal, 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 { auth: auth_service, diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index 404483c..92b3fd0 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -807,6 +807,7 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS user_settings: Arc::clone(&repo) as _, remote_goal: Arc::clone(&repo) as _, refresh_session: Arc::clone(&repo) as _, + federated_profile: None, }, services: Services { auth: auth_service, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index 5c57ada..3553652 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -460,6 +460,7 @@ async fn test_app() -> Router { user_settings: Arc::new(domain::testing::NoopUserSettingsRepository), remote_goal: Arc::new(domain::testing::NoopRemoteGoalRepository), refresh_session: Arc::new(domain::testing::PanicRefreshSessionRepository), + federated_profile: None, }, services: Services { auth: Arc::new(PanicAuth), diff --git a/spa/src/components/profile-view.tsx b/spa/src/components/profile-view.tsx index 307a8f5..994299d 100644 --- a/spa/src/components/profile-view.tsx +++ b/spa/src/components/profile-view.tsx @@ -2,7 +2,7 @@ import { Link } from "@tanstack/react-router" import { useCallback } from "react" import { useTranslation } from "react-i18next" 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -23,6 +23,10 @@ type ProfileViewProps = { userId?: string search?: string onSearchChange?: (value: string) => void + isFederated?: boolean + bio?: string + handle?: string + actorUrl?: string } export function ProfileView({ @@ -32,6 +36,9 @@ export function ProfileView({ userId, search, onSearchChange, + isFederated, + bio, + handle, }: ProfileViewProps) { const { t } = useTranslation() const initial = (data.username || "?")[0]?.toUpperCase() ?? "?" @@ -50,8 +57,15 @@ export function ProfileView({ {avatar && } {initial} -
+

{data.username}

+ {isFederated && handle && ( +
+ + {handle} +
+ )} + {bio &&

{bio}

}
{headerRight}
diff --git a/spa/src/lib/api/users.ts b/spa/src/lib/api/users.ts index 0519af2..a3b4ef3 100644 --- a/spa/src/lib/api/users.ts +++ b/spa/src/lib/api/users.ts @@ -86,6 +86,11 @@ export const userProfileResponseSchema = z.object({ history: z.array(monthActivityDtoSchema).optional(), trends: userTrendsDtoSchema.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 diff --git a/spa/src/routes/_app/index.tsx b/spa/src/routes/_app/index.tsx index 08a5ed9..dbd18ff 100644 --- a/spa/src/routes/_app/index.tsx +++ b/spa/src/routes/_app/index.tsx @@ -115,7 +115,7 @@ function FeedTab() { movie={entry.movie} review={entry.review} userName={entry.user_display_name} - userId={entry.is_federated ? undefined : entry.user_id} + userId={entry.user_id} isFederated={entry.is_federated} actorUrl={entry.actor_url} /> diff --git a/spa/src/routes/_app/users.$id.tsx b/spa/src/routes/_app/users.$id.tsx index 3e6d9a8..2c4594d 100644 --- a/spa/src/routes/_app/users.$id.tsx +++ b/spa/src/routes/_app/users.$id.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from "@tanstack/react-router" import { useState } from "react" 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 { Button } from "@/components/ui/button" import { ProfileView, ProfileSkeleton } from "@/components/profile-view" @@ -39,10 +39,14 @@ function UserProfilePage() { {data.goals.map((g) => ( @@ -51,7 +55,7 @@ function UserProfilePage() { ) : undefined } headerRight={ - !isSelf ? ( + !isSelf && !data.is_federated ? ( isFollowing ? ( + ) : undefined } />