diff --git a/crates/adapters/activitypub/src/event_handler.rs b/crates/adapters/activitypub/src/event_handler.rs index 55a6019..18d9f07 100644 --- a/crates/adapters/activitypub/src/event_handler.rs +++ b/crates/adapters/activitypub/src/event_handler.rs @@ -4,7 +4,7 @@ use domain::ports::EventHandler; use domain::{ errors::DomainError, events::DomainEvent, - ports::LocalApContentQuery, + ports::{LocalApContentQuery, UserFederationSettingsQuery}, value_objects::{MovieId, ReviewId, UserId}, }; use std::sync::Arc; @@ -17,6 +17,7 @@ use crate::urls::{actor_url, goal_url, review_url}; pub struct ActivityPubEventHandler { ap_service: Arc, content_query: Arc, + federation_settings: Arc, base_url: String, } @@ -24,11 +25,13 @@ impl ActivityPubEventHandler { pub fn new( ap_service: Arc, content_query: Arc, + federation_settings: Arc, base_url: String, ) -> Self { Self { ap_service, content_query, + federation_settings, base_url, } } @@ -131,6 +134,12 @@ impl EventHandler for ActivityPubEventHandler { impl ActivityPubEventHandler { async fn on_review_logged(&self, user_id: &UserId, review_id: &ReviewId) -> anyhow::Result<()> { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.reviews { + return Ok(()); + } + let review = match self.content_query.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), @@ -184,6 +193,12 @@ impl ActivityPubEventHandler { user_id: &UserId, review_id: &ReviewId, ) -> anyhow::Result<()> { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.reviews { + return Ok(()); + } + let review = match self.content_query.get_review_by_id(review_id).await? { Some(r) => r, None => return Ok(()), @@ -250,6 +265,12 @@ impl ActivityPubEventHandler { external_metadata_id: &Option, added_at: &chrono::NaiveDateTime, ) -> anyhow::Result<()> { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.watchlist { + return Ok(()); + } + use crate::urls::watchlist_entry_url; let ap_id = watchlist_entry_url(&self.base_url, user_id.value(), movie_id.value()); let actor = actor_url(&self.base_url, user_id.value()); @@ -316,6 +337,13 @@ impl ActivityPubEventHandler { for entry in entries { let review = entry.review(); let user_id = review.user_id(); + + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.reviews { + continue; + } + let ap_id = review_url(&self.base_url, review.id()); let actor = actor_url(&self.base_url, user_id.value()); @@ -343,12 +371,9 @@ impl ActivityPubEventHandler { user_id: &UserId, year: u16, ) -> anyhow::Result<()> { - if !self - .content_query - .get_user_federate_goals(user_id) - .await - .unwrap_or(false) - { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.goals { return Ok(()); } let Some((goal, current)) = self @@ -384,12 +409,9 @@ impl ActivityPubEventHandler { target_count: u32, is_create: bool, ) -> anyhow::Result<()> { - if !self - .content_query - .get_user_federate_goals(user_id) - .await - .unwrap_or(false) - { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.goals { return Ok(()); } let current = self @@ -418,12 +440,9 @@ impl ActivityPubEventHandler { } async fn on_goal_deleted(&self, user_id: &UserId, year: u16) -> anyhow::Result<()> { - if !self - .content_query - .get_user_federate_goals(user_id) - .await - .unwrap_or(false) - { + let flags = self.federation_settings.get_federation_flags(user_id).await + .unwrap_or(domain::ports::FederationFlags { goals: true, reviews: true, watchlist: true }); + if !flags.goals { return Ok(()); } let ap_id = goal_url(&self.base_url, user_id.value(), year); diff --git a/crates/adapters/activitypub/src/lib.rs b/crates/adapters/activitypub/src/lib.rs index 705d4e7..f1827e8 100644 --- a/crates/adapters/activitypub/src/lib.rs +++ b/crates/adapters/activitypub/src/lib.rs @@ -52,6 +52,7 @@ pub struct ActivityPubDeps { pub remote_goal_repo: std::sync::Arc, pub local_ap_content: std::sync::Arc, pub user_repo: std::sync::Arc, + pub federation_settings: std::sync::Arc, pub base_url: String, pub allow_registration: bool, pub event_publisher: std::sync::Arc, @@ -68,6 +69,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { remote_goal_repo, local_ap_content, user_repo, + federation_settings, base_url, allow_registration, event_publisher, @@ -129,6 +131,7 @@ pub async fn wire(deps: ActivityPubDeps) -> anyhow::Result { let event_handler = std::sync::Arc::new(ActivityPubEventHandler::new( std::sync::Arc::clone(&concrete), local_ap_content, + federation_settings, base_url, )) as std::sync::Arc; diff --git a/crates/adapters/postgres/migrations/0028_federation_privacy.sql b/crates/adapters/postgres/migrations/0028_federation_privacy.sql new file mode 100644 index 0000000..4e8ec86 --- /dev/null +++ b/crates/adapters/postgres/migrations/0028_federation_privacy.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/crates/adapters/postgres/src/ap_content.rs b/crates/adapters/postgres/src/ap_content.rs index a310370..1fa0eb3 100644 --- a/crates/adapters/postgres/src/ap_content.rs +++ b/crates/adapters/postgres/src/ap_content.rs @@ -229,23 +229,6 @@ impl LocalApContentQuery for PostgresApContentQuery { rows.into_iter().map(DiaryRow::into_domain).collect() } - async fn get_user_federate_goals(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; - - match row { - Some(r) => { - let val: i64 = r.try_get("federate_goals").unwrap_or(0); - Ok(val != 0) - } - None => Ok(false), - } - } - async fn get_goal_with_progress( &self, user_id: &UserId, diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 9248978..a8c0c1a 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -93,6 +93,7 @@ pub struct PostgresWireOutput { pub wrapup_stats: std::sync::Arc, pub goal: std::sync::Arc, pub user_settings: std::sync::Arc, + pub federation_settings: std::sync::Arc, pub remote_goal: std::sync::Arc, } @@ -108,6 +109,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; + let user_settings_repo = std::sync::Arc::new(user_settings::PostgresUserSettingsRepository::new(pool.clone())); + Ok(PostgresWireOutput { pool: pool.clone(), movie: std::sync::Arc::new(PostgresMovieRepository::new(pool.clone())) as _, @@ -125,9 +128,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { wrapup_repo: std::sync::Arc::new(PostgresWrapUpRepository::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(PostgresWrapUpStatsQuery::new(pool.clone())) as _, goal: std::sync::Arc::new(goals::PostgresGoalRepository::new(pool.clone())) as _, - user_settings: std::sync::Arc::new(user_settings::PostgresUserSettingsRepository::new( - pool.clone(), - )) as _, + user_settings: std::sync::Arc::clone(&user_settings_repo) as _, + federation_settings: user_settings_repo as _, remote_goal: std::sync::Arc::new(remote_goals::PostgresRemoteGoalRepository::new(pool)) as _, }) diff --git a/crates/adapters/postgres/src/user_settings.rs b/crates/adapters/postgres/src/user_settings.rs index e5e42f3..bc770d3 100644 --- a/crates/adapters/postgres/src/user_settings.rs +++ b/crates/adapters/postgres/src/user_settings.rs @@ -1,6 +1,9 @@ use async_trait::async_trait; use domain::{ - errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId, + errors::DomainError, + models::UserSettings, + ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository}, + value_objects::UserId, }; use sqlx::{PgPool, Row}; @@ -23,20 +26,25 @@ impl PostgresUserSettingsRepository { impl UserSettingsRepository for PostgresUserSettingsRepository { async fn get(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - - let row = - sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = $1") - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; + let row = sqlx::query( + "SELECT federate_goals, federate_reviews, federate_watchlist \ + FROM user_settings WHERE user_id = $1", + ) + .bind(&uid) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; match row { Some(r) => { - let federate: i64 = r.try_get("federate_goals").unwrap_or(0); + let goals: bool = r.try_get("federate_goals").unwrap_or(true); + let reviews: bool = r.try_get("federate_reviews").unwrap_or(true); + let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true); Ok(UserSettings::from_persistence( user_id.clone(), - federate != 0, + goals, + reviews, + watchlist, )) } None => Ok(UserSettings::new(user_id.clone())), @@ -45,18 +53,48 @@ impl UserSettingsRepository for PostgresUserSettingsRepository { async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { let uid = settings.user_id().value().to_string(); - let federate = if settings.federate_goals() { 1i64 } else { 0 }; - sqlx::query( - "INSERT INTO user_settings (user_id, federate_goals) VALUES ($1, $2) \ - ON CONFLICT (user_id) DO UPDATE SET federate_goals = $2", + "INSERT INTO user_settings (user_id, federate_goals, federate_reviews, federate_watchlist) \ + VALUES ($1, $2, $3, $4) \ + ON CONFLICT (user_id) DO UPDATE \ + SET federate_goals = $2, federate_reviews = $3, federate_watchlist = $4", ) .bind(&uid) - .bind(federate) + .bind(settings.federate_goals()) + .bind(settings.federate_reviews()) + .bind(settings.federate_watchlist()) .execute(&self.pool) .await .map_err(Self::map_err)?; - Ok(()) } } + +#[async_trait] +impl UserFederationSettingsQuery for PostgresUserSettingsRepository { + async fn get_federation_flags(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); + let row = sqlx::query( + "SELECT federate_goals, federate_reviews, federate_watchlist \ + FROM user_settings WHERE user_id = $1", + ) + .bind(&uid) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + match row { + Some(r) => { + let goals: bool = r.try_get("federate_goals").unwrap_or(true); + let reviews: bool = r.try_get("federate_reviews").unwrap_or(true); + let watchlist: bool = r.try_get("federate_watchlist").unwrap_or(true); + Ok(FederationFlags { goals, reviews, watchlist }) + } + None => Ok(FederationFlags { + goals: true, + reviews: true, + watchlist: true, + }), + } + } +} diff --git a/crates/adapters/sqlite/migrations/0028_federation_privacy.sql b/crates/adapters/sqlite/migrations/0028_federation_privacy.sql new file mode 100644 index 0000000..4e8ec86 --- /dev/null +++ b/crates/adapters/sqlite/migrations/0028_federation_privacy.sql @@ -0,0 +1,2 @@ +ALTER TABLE user_settings ADD COLUMN federate_reviews BOOLEAN NOT NULL DEFAULT TRUE; +ALTER TABLE user_settings ADD COLUMN federate_watchlist BOOLEAN NOT NULL DEFAULT TRUE; diff --git a/crates/adapters/sqlite/src/ap_content.rs b/crates/adapters/sqlite/src/ap_content.rs index 13c777b..22e73ca 100644 --- a/crates/adapters/sqlite/src/ap_content.rs +++ b/crates/adapters/sqlite/src/ap_content.rs @@ -5,7 +5,7 @@ use domain::{ ports::LocalApContentQuery, value_objects::{MovieId, ReviewId, UserId}, }; -use sqlx::{Row, SqlitePool}; +use sqlx::SqlitePool; use crate::models::{DiaryRow, MovieRow, ReviewRow, WatchlistRow}; @@ -169,23 +169,6 @@ impl LocalApContentQuery for SqliteApContentQuery { rows.into_iter().map(DiaryRow::into_domain).collect() } - async fn get_user_federate_goals(&self, user_id: &UserId) -> Result { - let uid = user_id.value().to_string(); - let row = sqlx::query("SELECT federate_goals FROM user_settings WHERE user_id = ?") - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; - - match row { - Some(r) => { - let val: i64 = r.try_get("federate_goals").unwrap_or(0); - Ok(val != 0) - } - None => Ok(false), - } - } - async fn get_goal_with_progress( &self, user_id: &UserId, diff --git a/crates/adapters/sqlite/src/lib.rs b/crates/adapters/sqlite/src/lib.rs index 237048d..8d0706e 100644 --- a/crates/adapters/sqlite/src/lib.rs +++ b/crates/adapters/sqlite/src/lib.rs @@ -89,6 +89,7 @@ pub struct SqliteWireOutput { pub wrapup_stats: std::sync::Arc, pub goal: std::sync::Arc, pub user_settings: std::sync::Arc, + pub federation_settings: std::sync::Arc, pub remote_goal: std::sync::Arc, } @@ -113,6 +114,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { .map_err(|e| anyhow::anyhow!("{e}")) .context("Database migration failed")?; + let user_settings_repo = std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new(pool.clone())); + Ok(SqliteWireOutput { pool: pool.clone(), movie: std::sync::Arc::new(SqliteMovieRepository::new(pool.clone())) as _, @@ -128,9 +131,8 @@ pub async fn wire(database_url: &str) -> anyhow::Result { wrapup_repo: std::sync::Arc::new(SqliteWrapUpRepository::new(pool.clone())) as _, wrapup_stats: std::sync::Arc::new(SqliteWrapUpStatsQuery::new(pool.clone())) as _, goal: std::sync::Arc::new(goals::SqliteGoalRepository::new(pool.clone())) as _, - user_settings: std::sync::Arc::new(user_settings::SqliteUserSettingsRepository::new( - pool.clone(), - )) as _, + user_settings: std::sync::Arc::clone(&user_settings_repo) as _, + federation_settings: user_settings_repo as _, remote_goal: std::sync::Arc::new(remote_goals::SqliteRemoteGoalRepository::new(pool)) as _, }) } diff --git a/crates/adapters/sqlite/src/user_settings.rs b/crates/adapters/sqlite/src/user_settings.rs index 24c6fe6..3ea5ea1 100644 --- a/crates/adapters/sqlite/src/user_settings.rs +++ b/crates/adapters/sqlite/src/user_settings.rs @@ -1,6 +1,9 @@ use async_trait::async_trait; use domain::{ - errors::DomainError, models::UserSettings, ports::UserSettingsRepository, value_objects::UserId, + errors::DomainError, + models::UserSettings, + ports::{FederationFlags, UserFederationSettingsQuery, UserSettingsRepository}, + value_objects::UserId, }; use sqlx::{Row, SqlitePool}; @@ -23,20 +26,25 @@ impl SqliteUserSettingsRepository { impl UserSettingsRepository for SqliteUserSettingsRepository { async fn get(&self, user_id: &UserId) -> Result { let uid = user_id.value().to_string(); - - let row = - sqlx::query("SELECT user_id, federate_goals FROM user_settings WHERE user_id = ?") - .bind(&uid) - .fetch_optional(&self.pool) - .await - .map_err(Self::map_err)?; + let row = sqlx::query( + "SELECT federate_goals, federate_reviews, federate_watchlist \ + FROM user_settings WHERE user_id = ?", + ) + .bind(&uid) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; match row { Some(r) => { - let federate: i64 = r.try_get("federate_goals").unwrap_or(0); + let goals: i64 = r.try_get("federate_goals").unwrap_or(1); + let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1); + let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1); Ok(UserSettings::from_persistence( user_id.clone(), - federate != 0, + goals != 0, + reviews != 0, + watchlist != 0, )) } None => Ok(UserSettings::new(user_id.clone())), @@ -45,15 +53,51 @@ impl UserSettingsRepository for SqliteUserSettingsRepository { async fn save(&self, settings: &UserSettings) -> Result<(), DomainError> { let uid = settings.user_id().value().to_string(); - let federate = if settings.federate_goals() { 1i64 } else { 0 }; - - sqlx::query("INSERT OR REPLACE INTO user_settings (user_id, federate_goals) VALUES (?, ?)") - .bind(&uid) - .bind(federate) - .execute(&self.pool) - .await - .map_err(Self::map_err)?; - + sqlx::query( + "INSERT OR REPLACE INTO user_settings \ + (user_id, federate_goals, federate_reviews, federate_watchlist) \ + VALUES (?, ?, ?, ?)", + ) + .bind(&uid) + .bind(if settings.federate_goals() { 1i64 } else { 0 }) + .bind(if settings.federate_reviews() { 1i64 } else { 0 }) + .bind(if settings.federate_watchlist() { 1i64 } else { 0 }) + .execute(&self.pool) + .await + .map_err(Self::map_err)?; Ok(()) } } + +#[async_trait] +impl UserFederationSettingsQuery for SqliteUserSettingsRepository { + async fn get_federation_flags(&self, user_id: &UserId) -> Result { + let uid = user_id.value().to_string(); + let row = sqlx::query( + "SELECT federate_goals, federate_reviews, federate_watchlist \ + FROM user_settings WHERE user_id = ?", + ) + .bind(&uid) + .fetch_optional(&self.pool) + .await + .map_err(Self::map_err)?; + + match row { + Some(r) => { + let goals: i64 = r.try_get("federate_goals").unwrap_or(1); + let reviews: i64 = r.try_get("federate_reviews").unwrap_or(1); + let watchlist: i64 = r.try_get("federate_watchlist").unwrap_or(1); + Ok(FederationFlags { + goals: goals != 0, + reviews: reviews != 0, + watchlist: watchlist != 0, + }) + } + None => Ok(FederationFlags { + goals: true, + reviews: true, + watchlist: true, + }), + } + } +} diff --git a/crates/api-types/src/goals.rs b/crates/api-types/src/goals.rs index a3e49c1..f3f8803 100644 --- a/crates/api-types/src/goals.rs +++ b/crates/api-types/src/goals.rs @@ -29,9 +29,13 @@ pub struct UpdateGoalRequest { #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UserSettingsDto { pub federate_goals: bool, + pub federate_reviews: bool, + pub federate_watchlist: bool, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateUserSettingsRequest { pub federate_goals: bool, + pub federate_reviews: bool, + pub federate_watchlist: bool, } diff --git a/crates/application/src/users/tests/get_settings.rs b/crates/application/src/users/tests/get_settings.rs index b11049d..47cd583 100644 --- a/crates/application/src/users/tests/get_settings.rs +++ b/crates/application/src/users/tests/get_settings.rs @@ -11,5 +11,7 @@ async fn returns_default_settings() { .await .unwrap(); - assert!(!settings.federate_goals()); + assert!(settings.federate_goals()); + assert!(settings.federate_reviews()); + assert!(settings.federate_watchlist()); } diff --git a/crates/application/src/users/tests/update_settings.rs b/crates/application/src/users/tests/update_settings.rs index 02f084c..bb17b56 100644 --- a/crates/application/src/users/tests/update_settings.rs +++ b/crates/application/src/users/tests/update_settings.rs @@ -13,7 +13,31 @@ async fn updates_federate_goals() { let settings_repo = InMemoryUserSettingsRepository::new(); let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); let user_settings = b.user_settings_repo.clone(); + let uid = Uuid::nil(); + crate::users::update_settings::execute( + user_settings.clone(), + UpdateUserSettingsCommand { + user_id: uid, + federate_goals: false, + federate_reviews: true, + federate_watchlist: true, + }, + ) + .await + .unwrap(); + + let settings = get_settings::execute(user_settings, uid).await.unwrap(); + assert!(!settings.federate_goals()); + assert!(settings.federate_reviews()); + assert!(settings.federate_watchlist()); +} + +#[tokio::test] +async fn updates_federate_reviews() { + let settings_repo = InMemoryUserSettingsRepository::new(); + let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); + let user_settings = b.user_settings_repo.clone(); let uid = Uuid::nil(); crate::users::update_settings::execute( @@ -21,6 +45,8 @@ async fn updates_federate_goals() { UpdateUserSettingsCommand { user_id: uid, federate_goals: true, + federate_reviews: false, + federate_watchlist: true, }, ) .await @@ -28,4 +54,31 @@ async fn updates_federate_goals() { let settings = get_settings::execute(user_settings, uid).await.unwrap(); assert!(settings.federate_goals()); + assert!(!settings.federate_reviews()); + assert!(settings.federate_watchlist()); +} + +#[tokio::test] +async fn updates_federate_watchlist() { + let settings_repo = InMemoryUserSettingsRepository::new(); + let b = TestContextBuilder::new().with_user_settings(Arc::clone(&settings_repo) as _); + let user_settings = b.user_settings_repo.clone(); + let uid = Uuid::nil(); + + crate::users::update_settings::execute( + user_settings.clone(), + UpdateUserSettingsCommand { + user_id: uid, + federate_goals: true, + federate_reviews: true, + federate_watchlist: false, + }, + ) + .await + .unwrap(); + + let settings = get_settings::execute(user_settings, uid).await.unwrap(); + assert!(settings.federate_goals()); + assert!(settings.federate_reviews()); + assert!(!settings.federate_watchlist()); } diff --git a/crates/application/src/users/update_settings.rs b/crates/application/src/users/update_settings.rs index fa11aaf..ed2db18 100644 --- a/crates/application/src/users/update_settings.rs +++ b/crates/application/src/users/update_settings.rs @@ -5,6 +5,8 @@ use domain::{errors::DomainError, ports::UserSettingsRepository, value_objects:: pub struct UpdateUserSettingsCommand { pub user_id: uuid::Uuid, pub federate_goals: bool, + pub federate_reviews: bool, + pub federate_watchlist: bool, } pub async fn execute( @@ -14,6 +16,8 @@ pub async fn execute( let uid = UserId::from_uuid(cmd.user_id); let mut settings = user_settings.get(&uid).await?; settings.set_federate_goals(cmd.federate_goals); + settings.set_federate_reviews(cmd.federate_reviews); + settings.set_federate_watchlist(cmd.federate_watchlist); user_settings.save(&settings).await } diff --git a/crates/domain/src/models/user_settings.rs b/crates/domain/src/models/user_settings.rs index a0cf172..df09f52 100644 --- a/crates/domain/src/models/user_settings.rs +++ b/crates/domain/src/models/user_settings.rs @@ -4,27 +4,34 @@ use crate::value_objects::UserId; pub struct UserSettings { user_id: UserId, federate_goals: bool, + federate_reviews: bool, + federate_watchlist: bool, } impl UserSettings { pub fn new(user_id: UserId) -> Self { Self { user_id, - federate_goals: false, + federate_goals: true, + federate_reviews: true, + federate_watchlist: true, } } - pub fn from_persistence(user_id: UserId, federate_goals: bool) -> Self { + pub fn from_persistence( + user_id: UserId, + federate_goals: bool, + federate_reviews: bool, + federate_watchlist: bool, + ) -> Self { Self { user_id, federate_goals, + federate_reviews, + federate_watchlist, } } - pub fn set_federate_goals(&mut self, value: bool) { - self.federate_goals = value; - } - pub fn user_id(&self) -> &UserId { &self.user_id } @@ -32,4 +39,24 @@ impl UserSettings { pub fn federate_goals(&self) -> bool { self.federate_goals } + + pub fn set_federate_goals(&mut self, value: bool) { + self.federate_goals = value; + } + + pub fn federate_reviews(&self) -> bool { + self.federate_reviews + } + + pub fn set_federate_reviews(&mut self, value: bool) { + self.federate_reviews = value; + } + + pub fn federate_watchlist(&self) -> bool { + self.federate_watchlist + } + + pub fn set_federate_watchlist(&mut self, value: bool) { + self.federate_watchlist = value; + } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index a1f477d..407d7b6 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -456,6 +456,17 @@ pub trait UserSettingsRepository: Send + Sync { async fn save(&self, settings: &UserSettings) -> Result<(), DomainError>; } +pub struct FederationFlags { + pub goals: bool, + pub reviews: bool, + pub watchlist: bool, +} + +#[async_trait] +pub trait UserFederationSettingsQuery: Send + Sync { + async fn get_federation_flags(&self, user_id: &UserId) -> Result; +} + #[async_trait] pub trait RemoteGoalRepository: Send + Sync { async fn save(&self, entry: RemoteGoalEntry) -> Result<(), DomainError>; @@ -502,8 +513,6 @@ pub trait LocalApContentQuery: Send + Sync { limit: usize, ) -> Result, DomainError>; - async fn get_user_federate_goals(&self, user_id: &UserId) -> Result; - async fn get_goal_with_progress( &self, user_id: &UserId, diff --git a/crates/domain/src/testing/in_memory.rs b/crates/domain/src/testing/in_memory.rs index 5c6f909..0983d11 100644 --- a/crates/domain/src/testing/in_memory.rs +++ b/crates/domain/src/testing/in_memory.rs @@ -18,9 +18,10 @@ use crate::{ collections::{PageParams, Paginated}, }, ports::{ - GoalRepository, ImportProfileRepository, ImportSessionRepository, MovieProfileRepository, - MovieRepository, RefreshSessionRepository, ReviewRepository, UserProfileFieldsRepository, - UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, + FederationFlags, GoalRepository, ImportProfileRepository, ImportSessionRepository, + MovieProfileRepository, MovieRepository, RefreshSessionRepository, ReviewRepository, + UserFederationSettingsQuery, UserProfileFieldsRepository, UserRepository, + UserSettingsRepository, WatchEventRepository, WatchlistRepository, WebhookTokenRepository, }, value_objects::{ @@ -441,6 +442,22 @@ impl UserSettingsRepository for InMemoryUserSettingsRepository { } } +#[async_trait] +impl UserFederationSettingsQuery for InMemoryUserSettingsRepository { + async fn get_federation_flags(&self, user_id: &UserId) -> Result { + let store = self.store.lock().unwrap(); + let settings = store + .get(&user_id.value()) + .cloned() + .unwrap_or_else(|| UserSettings::new(user_id.clone())); + Ok(FederationFlags { + goals: settings.federate_goals(), + reviews: settings.federate_reviews(), + watchlist: settings.federate_watchlist(), + }) + } +} + // ── InMemoryWebhookTokenRepository ────────────────────────────────────────── pub struct InMemoryWebhookTokenRepository { diff --git a/crates/presentation/src/factory.rs b/crates/presentation/src/factory.rs index 0d5a05a..b8f061f 100644 --- a/crates/presentation/src/factory.rs +++ b/crates/presentation/src/factory.rs @@ -36,6 +36,7 @@ pub struct DatabaseOutput { pub wrapup_repo: Arc, pub goal: Arc, pub user_settings: Arc, + pub federation_settings: std::sync::Arc, pub remote_goal: Arc, pub refresh_session: Arc, pub db_pool: DbPool, @@ -78,6 +79,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result wrapup_repo: w.wrapup_repo, goal: w.goal, user_settings: w.user_settings, + federation_settings: w.federation_settings, remote_goal: w.remote_goal, refresh_session: Arc::new(postgres::PostgresRefreshSessionAdapter::new( w.pool.clone(), @@ -119,6 +121,7 @@ pub async fn build_database_adapters(backend: &str, url: &str) -> anyhow::Result wrapup_repo: w.wrapup_repo, goal: w.goal, user_settings: w.user_settings, + federation_settings: w.federation_settings, remote_goal: w.remote_goal, refresh_session: Arc::new(sqlite::SqliteRefreshSessionAdapter::new(w.pool.clone())) as _, diff --git a/crates/presentation/src/handlers/goals.rs b/crates/presentation/src/handlers/goals.rs index ff9aba4..6257a31 100644 --- a/crates/presentation/src/handlers/goals.rs +++ b/crates/presentation/src/handlers/goals.rs @@ -176,6 +176,8 @@ pub async fn get_settings( .await?; Ok(Json(UserSettingsDto { federate_goals: settings.federate_goals(), + federate_reviews: settings.federate_reviews(), + federate_watchlist: settings.federate_watchlist(), })) } @@ -198,6 +200,8 @@ pub async fn update_settings( application::users::update_settings::UpdateUserSettingsCommand { user_id: user.0.value(), federate_goals: req.federate_goals, + federate_reviews: req.federate_reviews, + federate_watchlist: req.federate_watchlist, }, ) .await?; diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index 642787b..d07a5ad 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -119,6 +119,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { remote_goal_repo: Arc::clone(&db.remote_goal), local_ap_content: Arc::clone(&ap_content_repo), user_repo: Arc::clone(&db.user), + federation_settings: std::sync::Arc::clone(&db.federation_settings), base_url: app_config.base_url.clone(), allow_registration: app_config.allow_registration, event_publisher: Arc::clone(&ep), diff --git a/crates/worker/src/db.rs b/crates/worker/src/db.rs index b9a7b9b..2742565 100644 --- a/crates/worker/src/db.rs +++ b/crates/worker/src/db.rs @@ -4,7 +4,7 @@ use anyhow::Context; use domain::ports::{ ImageRefCommand, ImageRefQuery, ImportSessionRepository, LocalApContentQuery, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery, SearchCommand, - UserRepository, WatchEventRepository, + UserFederationSettingsQuery, UserRepository, WatchEventRepository, }; pub enum DbPool { @@ -30,6 +30,7 @@ pub struct WorkerDbOutput { pub wrapup_repo: Arc, pub remote_goal: Arc, pub refresh_session: Arc, + pub federation_settings: Arc, pub db_pool: DbPool, } @@ -64,6 +65,7 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result anyhow::Result anyhow::Result<()> { base_url, allow_registration, event_publisher: Arc::clone(&event_publisher), + federation_settings: std::sync::Arc::clone(&db.federation_settings), }) .await?; diff --git a/spa/src/lib/api/goals.ts b/spa/src/lib/api/goals.ts index 24077a0..3b50b5a 100644 --- a/spa/src/lib/api/goals.ts +++ b/spa/src/lib/api/goals.ts @@ -18,11 +18,15 @@ export type UpdateGoalRequest = { export const userSettingsDtoSchema = z.object({ federate_goals: z.boolean(), + federate_reviews: z.boolean(), + federate_watchlist: z.boolean(), }) export type UserSettingsDto = z.infer export type UpdateUserSettingsRequest = { federate_goals: boolean + federate_reviews: boolean + federate_watchlist: boolean } export function getGoals() { diff --git a/spa/src/locales/en.json b/spa/src/locales/en.json index 3c03fc2..b8caf29 100644 --- a/spa/src/locales/en.json +++ b/spa/src/locales/en.json @@ -178,6 +178,10 @@ "privacy": "Privacy", "federateGoals": "Share goals on Fediverse", "federateGoalsDesc": "Broadcast goal progress to followers", + "federateReviews": "Share reviews on Fediverse", + "federateReviewsDesc": "Broadcast diary entries to followers", + "federateWatchlist": "Share watchlist on Fediverse", + "federateWatchlistDesc": "Broadcast watchlist additions to followers", "export": "Export", "exportDesc": "Download your diary", "exportCsv": "CSV", diff --git a/spa/src/routes/_app/settings/index.tsx b/spa/src/routes/_app/settings/index.tsx index d95fa47..9313fce 100644 --- a/spa/src/routes/_app/settings/index.tsx +++ b/spa/src/routes/_app/settings/index.tsx @@ -3,9 +3,11 @@ import { useTranslation } from "react-i18next" import { useMutation } from "@tanstack/react-query" import { ArrowLeft, + BookOpen, ChevronRight, Download, Key, + List, LogOut, RefreshCw, ShieldBan, @@ -19,6 +21,7 @@ import { Switch } from "@/components/ui/switch" import { useAuth, useIsAdmin } from "@/components/auth-provider" import { reindexSearch } from "@/lib/api/users" import { useSettings, useUpdateSettings } from "@/hooks/use-goals" +import { UpdateUserSettingsRequest } from "@/lib/api/goals" import { useDocumentTitle } from "@/hooks/use-document-title" export const Route = createFileRoute("/_app/settings/")({ @@ -128,6 +131,18 @@ function PrivacySection() { const { data: settings } = useSettings() const updateMutation = useUpdateSettings() + const disabled = updateMutation.isPending + + const toggle = (patch: Partial) => { + if (!settings) return + updateMutation.mutate({ + federate_goals: settings.federate_goals, + federate_reviews: settings.federate_reviews, + federate_watchlist: settings.federate_watchlist, + ...patch, + }) + } + return (

@@ -145,11 +160,41 @@ function PrivacySection() {

- updateMutation.mutate({ federate_goals: checked }) - } - disabled={updateMutation.isPending} + checked={settings?.federate_goals ?? true} + onCheckedChange={(checked) => toggle({ federate_goals: checked })} + disabled={disabled} + /> + +
+ + + +
+

{t("settings.federateReviews")}

+

+ {t("settings.federateReviewsDesc")} +

+
+ toggle({ federate_reviews: checked })} + disabled={disabled} + /> +
+
+ + + +
+

{t("settings.federateWatchlist")}

+

+ {t("settings.federateWatchlistDesc")} +

+
+ toggle({ federate_watchlist: checked })} + disabled={disabled} />