diff --git a/crates/adapters/activitypub-base/src/lib.rs b/crates/adapters/activitypub-base/src/lib.rs index ec7c810..515ebdb 100644 --- a/crates/adapters/activitypub-base/src/lib.rs +++ b/crates/adapters/activitypub-base/src/lib.rs @@ -16,6 +16,7 @@ pub use urls::AS_PUBLIC; pub mod user; pub mod webfinger; +pub use activitypub_federation::kinds::object::NoteType; pub use content::ApObjectHandler; pub use data::FederationData; pub use error::Error; @@ -25,4 +26,3 @@ pub use repository::{ }; pub use service::ActivityPubService; pub use user::{ApProfileField, ApUser, ApUserRepository}; -pub use activitypub_federation::kinds::object::NoteType; diff --git a/crates/adapters/activitypub-base/src/service.rs b/crates/adapters/activitypub-base/src/service.rs index 9bc1b77..139cbd2 100644 --- a/crates/adapters/activitypub-base/src/service.rs +++ b/crates/adapters/activitypub-base/src/service.rs @@ -154,9 +154,17 @@ impl ActivityPubService { .map_err(|e| anyhow::anyhow!("{e}"))?; let followers = data.federation_repo.get_followers(local_user_id).await?; - let blocked = data.federation_repo.get_blocked_actors(local_user_id).await.unwrap_or_default(); + let blocked = data + .federation_repo + .get_blocked_actors(local_user_id) + .await + .unwrap_or_default(); let blocked_set: std::collections::HashSet = blocked.into_iter().collect(); - let blocked_domains = data.federation_repo.get_blocked_domains().await.unwrap_or_default(); + let blocked_domains = data + .federation_repo + .get_blocked_domains() + .await + .unwrap_or_default(); let blocked_domain_set: std::collections::HashSet = blocked_domains.into_iter().map(|d| d.domain).collect(); @@ -225,14 +233,18 @@ impl ActivityPubService { .map_err(|e| anyhow::anyhow!("{e}"))?; let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { return Ok(()); }; let announce = crate::activities::AnnounceActivity { id: announce_id, kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), object: object_ap_id, published: Some(chrono::Utc::now()), to: vec![crate::urls::AS_PUBLIC.to_string()], @@ -270,17 +282,22 @@ impl ActivityPubService { )) .map_err(|e| anyhow::anyhow!("{e}"))?; - let undo_id = crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; + let undo_id = + crate::urls::activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?; let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { return Ok(()); }; let undo = crate::activities::UndoActivity { id: undo_id, kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), object: serde_json::json!({ "type": "Announce", "id": announce_id.to_string(), @@ -298,7 +315,10 @@ impl ActivityPubService { .await?; let failures = send_with_retry(sends, &data).await; if !failures.is_empty() { - tracing::warn!(count = failures.len(), "some Undo(Announce) deliveries failed"); + tracing::warn!( + count = failures.len(), + "some Undo(Announce) deliveries failed" + ); } Ok(()) } @@ -620,7 +640,9 @@ impl ActivityPubService { ap_id: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { return Ok(()); }; @@ -655,7 +677,9 @@ impl ActivityPubService { object: serde_json::Value, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { return Ok(()); }; @@ -683,7 +707,9 @@ impl ActivityPubService { watchlist_entry_ap_id: Url, ) -> anyhow::Result<()> { let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self.accepted_follower_inboxes(&data, local_user_id).await? else { + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, local_user_id).await? + else { return Ok(()); }; @@ -1157,10 +1183,10 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { ) -> Result<(), domain::errors::DomainError> { let user_uuid = author_user_id.as_uuid(); let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self - .accepted_follower_inboxes(&data, user_uuid) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, user_uuid) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? else { return Ok(()); }; @@ -1171,7 +1197,9 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { let create = crate::activities::CreateActivity { id: ap_id, kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), object: note, to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], @@ -1214,10 +1242,10 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { ) -> Result<(), domain::errors::DomainError> { let user_uuid = author_user_id.as_uuid(); let data = self.federation_config.to_request_data(); - let Some((local_actor, inboxes)) = self - .accepted_follower_inboxes(&data, user_uuid) - .await - .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? + let Some((local_actor, inboxes)) = + self.accepted_follower_inboxes(&data, user_uuid) + .await + .map_err(|e| domain::errors::DomainError::Internal(e.to_string()))? else { return Ok(()); }; @@ -1234,7 +1262,9 @@ impl domain::ports::OutboundFederationPort for ActivityPubService { let update = crate::activities::UpdateActivity { id: update_id, kind: Default::default(), - actor: activitypub_federation::fetch::object_id::ObjectId::from(local_actor.ap_id.clone()), + actor: activitypub_federation::fetch::object_id::ObjectId::from( + local_actor.ap_id.clone(), + ), object: note, to: vec![crate::urls::AS_PUBLIC.to_string()], cc: vec![local_actor.followers_url.to_string()], diff --git a/crates/application/src/services/federation_event.rs b/crates/application/src/services/federation_event.rs index 4a0e3c6..5fa7c10 100644 --- a/crates/application/src/services/federation_event.rs +++ b/crates/application/src/services/federation_event.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use domain::{ errors::DomainError, events::DomainEvent, @@ -6,55 +5,91 @@ use domain::{ ports::{OutboundFederationPort, ThoughtRepository, UserRepository}, value_objects::ThoughtId, }; +use std::sync::Arc; pub struct FederationEventService { - pub thoughts: Arc, - pub users: Arc, - pub ap: Arc, - pub base_url: String, + pub thoughts: Arc, + pub users: Arc, + pub ap: Arc, + pub base_url: String, } impl FederationEventService { fn object_ap_id(&self, thought: &Thought, thought_id: &ThoughtId) -> String { - thought.ap_id.clone().unwrap_or_else(|| { - format!("{}/thoughts/{}", self.base_url, thought_id) - }) + thought + .ap_id + .clone() + .unwrap_or_else(|| format!("{}/thoughts/{}", self.base_url, thought_id)) } pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { - DomainEvent::ThoughtCreated { thought_id, user_id, .. } => { + DomainEvent::ThoughtCreated { + thought_id, + user_id, + .. + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) if t.local && matches!(t.visibility, Visibility::Public | Visibility::Unlisted) => t, + Some(t) + if t.local + && matches!( + t.visibility, + Visibility::Public | Visibility::Unlisted + ) => + { + t + } _ => return Ok(()), }; let user = match self.users.find_by_id(user_id).await? { Some(u) => u, None => return Ok(()), }; - self.ap.broadcast_create(user_id, &thought, user.username.as_str()).await + self.ap + .broadcast_create(user_id, &thought, user.username.as_str()) + .await } - DomainEvent::ThoughtDeleted { thought_id, user_id } => { + DomainEvent::ThoughtDeleted { + thought_id, + user_id, + } => { // No DB lookup — thought is already deleted when this event fires. // No locality guard: delete commands only reach local thoughts via the use case. let ap_id = format!("{}/thoughts/{}", self.base_url, thought_id); self.ap.broadcast_delete(user_id, &ap_id).await } - DomainEvent::ThoughtUpdated { thought_id, user_id } => { + DomainEvent::ThoughtUpdated { + thought_id, + user_id, + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { - Some(t) if t.local && matches!(t.visibility, Visibility::Public | Visibility::Unlisted) => t, + Some(t) + if t.local + && matches!( + t.visibility, + Visibility::Public | Visibility::Unlisted + ) => + { + t + } _ => return Ok(()), }; let user = match self.users.find_by_id(user_id).await? { Some(u) => u, None => return Ok(()), }; - self.ap.broadcast_update(user_id, &thought, user.username.as_str()).await + self.ap + .broadcast_update(user_id, &thought, user.username.as_str()) + .await } - DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { + DomainEvent::BoostAdded { + boost_id: _, + user_id, + thought_id, + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { Some(t) => t, None => return Ok(()), @@ -63,13 +98,18 @@ impl FederationEventService { self.ap.broadcast_announce(user_id, &object_ap_id).await } - DomainEvent::BoostRemoved { user_id, thought_id } => { + DomainEvent::BoostRemoved { + user_id, + thought_id, + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { Some(t) => t, None => return Ok(()), }; let object_ap_id = self.object_ap_id(&thought, thought_id); - self.ap.broadcast_undo_announce(user_id, &object_ap_id).await + self.ap + .broadcast_undo_announce(user_id, &object_ap_id) + .await } _ => Ok(()), @@ -96,16 +136,21 @@ mod tests { #[derive(Default)] struct SpyPort { - created: Mutex>, - deleted: Mutex>, - updated: Mutex>, - announced: Mutex>, + created: Mutex>, + deleted: Mutex>, + updated: Mutex>, + announced: Mutex>, undo_announced: Mutex>, } #[async_trait] impl OutboundFederationPort for SpyPort { - async fn broadcast_create(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { + async fn broadcast_create( + &self, + _: &UserId, + thought: &Thought, + _: &str, + ) -> Result<(), DomainError> { self.created.lock().unwrap().push(thought.id.clone()); Ok(()) } @@ -113,7 +158,12 @@ mod tests { self.deleted.lock().unwrap().push(ap_id.to_string()); Ok(()) } - async fn broadcast_update(&self, _: &UserId, thought: &Thought, _: &str) -> Result<(), DomainError> { + async fn broadcast_update( + &self, + _: &UserId, + thought: &Thought, + _: &str, + ) -> Result<(), DomainError> { self.updated.lock().unwrap().push(thought.id.clone()); Ok(()) } @@ -121,7 +171,11 @@ mod tests { self.announced.lock().unwrap().push(ap_id.to_string()); Ok(()) } - async fn broadcast_undo_announce(&self, _: &UserId, ap_id: &str) -> Result<(), DomainError> { + async fn broadcast_undo_announce( + &self, + _: &UserId, + ap_id: &str, + ) -> Result<(), DomainError> { self.undo_announced.lock().unwrap().push(ap_id.to_string()); Ok(()) } @@ -138,9 +192,13 @@ mod tests { fn local_thought(author_id: UserId) -> Thought { Thought::new_local( - ThoughtId::new(), author_id, + ThoughtId::new(), + author_id, Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ) } @@ -259,7 +317,10 @@ mod tests { let announced = spy.announced.lock().unwrap(); assert_eq!(announced.len(), 1); - assert_eq!(announced[0], format!("https://example.com/thoughts/{}", thought.id)); + assert_eq!( + announced[0], + format!("https://example.com/thoughts/{}", thought.id) + ); } #[tokio::test] @@ -282,7 +343,10 @@ mod tests { .unwrap(); let announced = spy.announced.lock().unwrap(); - assert_eq!(announced[0], "https://mastodon.social/users/bob/statuses/123"); + assert_eq!( + announced[0], + "https://mastodon.social/users/bob/statuses/123" + ); } #[tokio::test] @@ -290,9 +354,13 @@ mod tests { let store = TestStore::default(); let alice = alice(); let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("private").unwrap(), - None, Visibility::Direct, None, false, + None, + Visibility::Direct, + None, + false, ); store.users.lock().unwrap().push(alice.clone()); store.thoughts.lock().unwrap().push(thought.clone()); @@ -315,9 +383,13 @@ mod tests { let store = TestStore::default(); let alice = alice(); let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("for followers").unwrap(), - None, Visibility::Followers, None, false, + None, + Visibility::Followers, + None, + false, ); store.users.lock().unwrap().push(alice.clone()); store.thoughts.lock().unwrap().push(thought.clone()); @@ -344,7 +416,9 @@ mod tests { svc.process(&DomainEvent::UserBlocked { blocker_id: UserId::new(), blocked_id: UserId::new(), - }).await.unwrap(); + }) + .await + .unwrap(); assert!(spy.created.lock().unwrap().is_empty()); assert!(spy.deleted.lock().unwrap().is_empty()); @@ -391,7 +465,10 @@ mod tests { let undo_announced = spy.undo_announced.lock().unwrap(); assert_eq!(undo_announced.len(), 1); - assert_eq!(undo_announced[0], format!("https://example.com/thoughts/{}", thought.id)); + assert_eq!( + undo_announced[0], + format!("https://example.com/thoughts/{}", thought.id) + ); } #[tokio::test] @@ -414,7 +491,10 @@ mod tests { let undo_announced = spy.undo_announced.lock().unwrap(); assert_eq!(undo_announced.len(), 1); - assert_eq!(undo_announced[0], "https://mastodon.social/users/bob/statuses/456"); + assert_eq!( + undo_announced[0], + "https://mastodon.social/users/bob/statuses/456" + ); } #[tokio::test] diff --git a/crates/application/src/services/notification_event.rs b/crates/application/src/services/notification_event.rs index 5844623..2538e01 100644 --- a/crates/application/src/services/notification_event.rs +++ b/crates/application/src/services/notification_event.rs @@ -1,4 +1,3 @@ -use std::sync::Arc; use chrono::Utc; use domain::{ errors::DomainError, @@ -7,9 +6,10 @@ use domain::{ ports::{NotificationRepository, ThoughtRepository}, value_objects::{NotificationId, UserId}, }; +use std::sync::Arc; pub struct NotificationEventService { - pub thoughts: Arc, + pub thoughts: Arc, pub notifications: Arc, } @@ -20,50 +20,75 @@ fn is_self_action(thought_author: &UserId, actor: &UserId) -> bool { impl NotificationEventService { pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { match event { - DomainEvent::LikeAdded { like_id: _, user_id, thought_id } => { + DomainEvent::LikeAdded { + like_id: _, + user_id, + thought_id, + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { Some(t) => t, None => return Ok(()), }; - if is_self_action(&thought.user_id, user_id) { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Like, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await + if is_self_action(&thought.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + notification_type: NotificationType::Like, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }) + .await } - DomainEvent::BoostAdded { boost_id: _, user_id, thought_id } => { + DomainEvent::BoostAdded { + boost_id: _, + user_id, + thought_id, + } => { let thought = match self.thoughts.find_by_id(thought_id).await? { Some(t) => t, None => return Ok(()), }; - if is_self_action(&thought.user_id, user_id) { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: thought.user_id, - notification_type: NotificationType::Boost, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await + if is_self_action(&thought.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: thought.user_id, + notification_type: NotificationType::Boost, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }) + .await } - DomainEvent::FollowAccepted { follower_id, following_id } => { - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: following_id.clone(), - notification_type: NotificationType::Follow, - from_user_id: Some(follower_id.clone()), - thought_id: None, - read: false, - created_at: Utc::now(), - }).await + DomainEvent::FollowAccepted { + follower_id, + following_id, + } => { + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: following_id.clone(), + notification_type: NotificationType::Follow, + from_user_id: Some(follower_id.clone()), + thought_id: None, + read: false, + created_at: Utc::now(), + }) + .await } - DomainEvent::ThoughtCreated { thought_id, user_id, in_reply_to_id } => { + DomainEvent::ThoughtCreated { + thought_id, + user_id, + in_reply_to_id, + } => { let reply_to_id = match in_reply_to_id { Some(id) => id, None => return Ok(()), @@ -72,16 +97,20 @@ impl NotificationEventService { Some(t) => t, None => return Ok(()), }; - if is_self_action(&original.user_id, user_id) { return Ok(()); } - self.notifications.save(&Notification { - id: NotificationId::new(), - user_id: original.user_id, - notification_type: NotificationType::Reply, - from_user_id: Some(user_id.clone()), - thought_id: Some(thought_id.clone()), - read: false, - created_at: Utc::now(), - }).await + if is_self_action(&original.user_id, user_id) { + return Ok(()); + } + self.notifications + .save(&Notification { + id: NotificationId::new(), + user_id: original.user_id, + notification_type: NotificationType::Reply, + from_user_id: Some(user_id.clone()), + thought_id: Some(thought_id.clone()), + read: false, + created_at: Utc::now(), + }) + .await } _ => Ok(()), } @@ -92,7 +121,10 @@ impl NotificationEventService { mod tests { use super::*; use domain::{ - models::{thought::{Thought, Visibility}, user::User}, + models::{ + thought::{Thought, Visibility}, + user::User, + }, testing::TestStore, value_objects::*, }; @@ -113,9 +145,13 @@ mod tests { let alice = alice(); let bob_id = UserId::new(); let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); store.thoughts.lock().unwrap().push(thought.clone()); let svc = NotificationEventService { @@ -126,10 +162,15 @@ mod tests { like_id: LikeId::new(), user_id: bob_id, thought_id: thought.id.clone(), - }).await.unwrap(); + }) + .await + .unwrap(); let notifs = store.notifications.lock().unwrap(); assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Like)); + assert!(matches!( + notifs[0].notification_type, + NotificationType::Like + )); } #[tokio::test] @@ -137,9 +178,13 @@ mod tests { let store = TestStore::default(); let alice = alice(); let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); store.thoughts.lock().unwrap().push(thought.clone()); let svc = NotificationEventService { @@ -150,7 +195,9 @@ mod tests { like_id: LikeId::new(), user_id: alice.id.clone(), thought_id: thought.id.clone(), - }).await.unwrap(); + }) + .await + .unwrap(); assert!(store.notifications.lock().unwrap().is_empty()); } @@ -166,10 +213,15 @@ mod tests { svc.process(&DomainEvent::FollowAccepted { follower_id: bob_id, following_id: alice.id.clone(), - }).await.unwrap(); + }) + .await + .unwrap(); let notifs = store.notifications.lock().unwrap(); assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Follow)); + assert!(matches!( + notifs[0].notification_type, + NotificationType::Follow + )); } #[tokio::test] @@ -178,9 +230,13 @@ mod tests { let alice = alice(); let bob_id = UserId::new(); let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("original").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); store.thoughts.lock().unwrap().push(original.clone()); let svc = NotificationEventService { @@ -191,10 +247,15 @@ mod tests { thought_id: ThoughtId::new(), user_id: bob_id, in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); + }) + .await + .unwrap(); let notifs = store.notifications.lock().unwrap(); assert_eq!(notifs.len(), 1); - assert!(matches!(notifs[0].notification_type, NotificationType::Reply)); + assert!(matches!( + notifs[0].notification_type, + NotificationType::Reply + )); } #[tokio::test] @@ -202,9 +263,13 @@ mod tests { let store = TestStore::default(); let alice = alice(); let original = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("original").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); store.thoughts.lock().unwrap().push(original.clone()); let svc = NotificationEventService { @@ -215,7 +280,9 @@ mod tests { thought_id: ThoughtId::new(), user_id: alice.id.clone(), in_reply_to_id: Some(original.id.clone()), - }).await.unwrap(); + }) + .await + .unwrap(); assert!(store.notifications.lock().unwrap().is_empty()); } @@ -224,9 +291,13 @@ mod tests { let store = TestStore::default(); let alice = alice(); let thought = Thought::new_local( - ThoughtId::new(), alice.id.clone(), + ThoughtId::new(), + alice.id.clone(), Content::new_local("hello").unwrap(), - None, Visibility::Public, None, false, + None, + Visibility::Public, + None, + false, ); store.thoughts.lock().unwrap().push(thought.clone()); let svc = NotificationEventService { @@ -237,7 +308,9 @@ mod tests { boost_id: BoostId::new(), user_id: alice.id.clone(), thought_id: thought.id.clone(), - }).await.unwrap(); + }) + .await + .unwrap(); assert!(store.notifications.lock().unwrap().is_empty()); } } diff --git a/crates/application/src/use_cases/api_keys.rs b/crates/application/src/use_cases/api_keys.rs index 1f0ef56..17e1716 100644 --- a/crates/application/src/use_cases/api_keys.rs +++ b/crates/application/src/use_cases/api_keys.rs @@ -6,19 +6,36 @@ use domain::{ value_objects::{ApiKeyId, UserId}, }; -pub async fn list_api_keys(keys: &dyn ApiKeyRepository, user_id: &UserId) -> Result, DomainError> { +pub async fn list_api_keys( + keys: &dyn ApiKeyRepository, + user_id: &UserId, +) -> Result, DomainError> { keys.list_for_user(user_id).await } -pub async fn create_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, name: String) -> Result<(ApiKey, String), DomainError> { +pub async fn create_api_key( + keys: &dyn ApiKeyRepository, + user_id: &UserId, + name: String, +) -> Result<(ApiKey, String), DomainError> { let raw_key = uuid::Uuid::new_v4().to_string().replace('-', ""); let key_hash = sha256_hex(&raw_key); - let key = ApiKey { id: ApiKeyId::new(), user_id: user_id.clone(), key_hash, name, created_at: Utc::now() }; + let key = ApiKey { + id: ApiKeyId::new(), + user_id: user_id.clone(), + key_hash, + name, + created_at: Utc::now(), + }; keys.save(&key).await?; Ok((key, raw_key)) } -pub async fn delete_api_key(keys: &dyn ApiKeyRepository, user_id: &UserId, key_id: &ApiKeyId) -> Result<(), DomainError> { +pub async fn delete_api_key( + keys: &dyn ApiKeyRepository, + user_id: &UserId, + key_id: &ApiKeyId, +) -> Result<(), DomainError> { keys.delete(key_id, user_id).await } @@ -37,7 +54,9 @@ mod tests { async fn create_key_saves_hashed_not_raw() { let store = TestStore::default(); let uid = UserId::new(); - let (key, raw) = create_api_key(&store, &uid, "my-key".to_string()).await.unwrap(); + let (key, raw) = create_api_key(&store, &uid, "my-key".to_string()) + .await + .unwrap(); assert_ne!(key.key_hash, raw, "stored hash must differ from raw key"); assert!(!key.key_hash.is_empty()); assert_eq!(key.name, "my-key"); @@ -50,7 +69,9 @@ mod tests { use sha2::{Digest, Sha256}; let store = TestStore::default(); let uid = UserId::new(); - let (key, raw) = create_api_key(&store, &uid, "test".to_string()).await.unwrap(); + let (key, raw) = create_api_key(&store, &uid, "test".to_string()) + .await + .unwrap(); let expected_hash = hex::encode(Sha256::digest(raw.as_bytes())); assert_eq!(key.key_hash, expected_hash); } @@ -69,7 +90,9 @@ mod tests { let store = TestStore::default(); let alice = UserId::new(); let bob = UserId::new(); - create_api_key(&store, &alice, "a".to_string()).await.unwrap(); + create_api_key(&store, &alice, "a".to_string()) + .await + .unwrap(); create_api_key(&store, &bob, "b".to_string()).await.unwrap(); let alice_keys = list_api_keys(&store, &alice).await.unwrap(); assert_eq!(alice_keys.len(), 1); diff --git a/crates/application/src/use_cases/auth.rs b/crates/application/src/use_cases/auth.rs index 9244c52..2b74517 100644 --- a/crates/application/src/use_cases/auth.rs +++ b/crates/application/src/use_cases/auth.rs @@ -6,9 +6,16 @@ use domain::{ value_objects::{Email, UserId, Username}, }; -pub struct RegisterInput { pub username: String, pub email: String, pub password: String } +pub struct RegisterInput { + pub username: String, + pub email: String, + pub password: String, +} #[derive(Debug)] -pub struct RegisterOutput { pub user: User, pub token: String } +pub struct RegisterOutput { + pub user: User, + pub token: String, +} pub async fn register( users: &dyn UserRepository, @@ -28,14 +35,27 @@ pub async fn register( let hash = hasher.hash(&input.password).await?; let user = User::new_local(UserId::new(), username, email, hash); users.save(&user).await?; - events.publish(&DomainEvent::UserRegistered { user_id: user.id.clone() }).await?; + events + .publish(&DomainEvent::UserRegistered { + user_id: user.id.clone(), + }) + .await?; let token = auth.generate_token(&user.id)?; - Ok(RegisterOutput { user, token: token.token }) + Ok(RegisterOutput { + user, + token: token.token, + }) } -pub struct LoginInput { pub email: String, pub password: String } +pub struct LoginInput { + pub email: String, + pub password: String, +} #[derive(Debug)] -pub struct LoginOutput { pub user: User, pub token: String } +pub struct LoginOutput { + pub user: User, + pub token: String, +} pub async fn login( users: &dyn UserRepository, @@ -44,12 +64,18 @@ pub async fn login( input: LoginInput, ) -> Result { let email = Email::new(input.email)?; - let user = users.find_by_email(&email).await?.ok_or(DomainError::Unauthorized)?; + let user = users + .find_by_email(&email) + .await? + .ok_or(DomainError::Unauthorized)?; if !hasher.verify(&input.password, &user.password_hash).await? { return Err(DomainError::Unauthorized); } let token = auth.generate_token(&user.id)?; - Ok(LoginOutput { user, token: token.token }) + Ok(LoginOutput { + user, + token: token.token, + }) } #[cfg(test)] @@ -65,29 +91,45 @@ mod tests { }; struct FakeHasher; - #[async_trait] impl PasswordHasher for FakeHasher { - async fn hash(&self, plain: &str) -> Result { Ok(PasswordHash(plain.to_string())) } - async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { Ok(plain == hash.0) } + #[async_trait] + impl PasswordHasher for FakeHasher { + async fn hash(&self, plain: &str) -> Result { + Ok(PasswordHash(plain.to_string())) + } + async fn verify(&self, plain: &str, hash: &PasswordHash) -> Result { + Ok(plain == hash.0) + } } struct FakeAuth; impl AuthService for FakeAuth { fn generate_token(&self, uid: &UserId) -> Result { - Ok(GeneratedToken { token: uid.to_string(), user_id: uid.clone() }) + Ok(GeneratedToken { + token: uid.to_string(), + user_id: uid.clone(), + }) } fn validate_token(&self, token: &str) -> Result { - Ok(UserId::from_uuid(uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?)) + Ok(UserId::from_uuid( + uuid::Uuid::parse_str(token).map_err(|_| DomainError::Unauthorized)?, + )) } } fn input() -> RegisterInput { - RegisterInput { username: "alice".into(), email: "alice@ex.com".into(), password: "pw".into() } + RegisterInput { + username: "alice".into(), + email: "alice@ex.com".into(), + password: "pw".into(), + } } #[tokio::test] async fn register_creates_user() { let store = TestStore::default(); - let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); + let out = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); assert_eq!(out.user.username.as_str(), "alice"); assert!(!out.token.is_empty()); } @@ -95,31 +137,61 @@ mod tests { #[tokio::test] async fn register_rejects_duplicate_username() { let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); - let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap_err(); + register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); + let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap_err(); assert!(matches!(err, DomainError::Conflict(_))); } #[tokio::test] async fn login_succeeds_with_correct_password() { let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); - let out = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "pw".into() }).await.unwrap(); + register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); + let out = login( + &store, + &FakeHasher, + &FakeAuth, + LoginInput { + email: "alice@ex.com".into(), + password: "pw".into(), + }, + ) + .await + .unwrap(); assert!(!out.token.is_empty()); } #[tokio::test] async fn login_fails_wrong_password() { let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); - let err = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "alice@ex.com".into(), password: "wrong".into() }).await.unwrap_err(); + register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); + let err = login( + &store, + &FakeHasher, + &FakeAuth, + LoginInput { + email: "alice@ex.com".into(), + password: "wrong".into(), + }, + ) + .await + .unwrap_err(); assert!(matches!(err, DomainError::Unauthorized)); } #[tokio::test] async fn register_publishes_user_registered_event() { let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &store, input()).await.unwrap(); + register(&store, &FakeHasher, &FakeAuth, &store, input()) + .await + .unwrap(); let events = store.events.lock().unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0], DomainEvent::UserRegistered { .. })); @@ -128,15 +200,39 @@ mod tests { #[tokio::test] async fn login_fails_for_nonexistent_user() { let store = TestStore::default(); - let err = login(&store, &FakeHasher, &FakeAuth, LoginInput { email: "ghost@ex.com".into(), password: "pass".into() }).await.unwrap_err(); + let err = login( + &store, + &FakeHasher, + &FakeAuth, + LoginInput { + email: "ghost@ex.com".into(), + password: "pass".into(), + }, + ) + .await + .unwrap_err(); assert!(matches!(err, DomainError::Unauthorized)); } #[tokio::test] async fn register_rejects_duplicate_email() { let store = TestStore::default(); - register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()).await.unwrap(); - let err = register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, RegisterInput { username: "alice2".into(), email: "alice@ex.com".into(), password: "pass2".into() }).await.unwrap_err(); + register(&store, &FakeHasher, &FakeAuth, &NoOpEventPublisher, input()) + .await + .unwrap(); + let err = register( + &store, + &FakeHasher, + &FakeAuth, + &NoOpEventPublisher, + RegisterInput { + username: "alice2".into(), + email: "alice@ex.com".into(), + password: "pass2".into(), + }, + ) + .await + .unwrap_err(); assert!(matches!(err, DomainError::Conflict(_))); } } diff --git a/crates/application/src/use_cases/feed.rs b/crates/application/src/use_cases/feed.rs index 22b4489..8b70d87 100644 --- a/crates/application/src/use_cases/feed.rs +++ b/crates/application/src/use_cases/feed.rs @@ -8,32 +8,64 @@ use domain::{ value_objects::UserId, }; -pub async fn get_home_feed(feed: &dyn FeedRepository, follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { +pub async fn get_home_feed( + feed: &dyn FeedRepository, + follows: &dyn FollowRepository, + user_id: &UserId, + page: PageParams, +) -> Result, DomainError> { let following_ids = follows.get_accepted_following_ids(user_id).await?; feed.home_feed(&following_ids, &page, Some(user_id)).await } -pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserId>, page: PageParams) -> Result, DomainError> { +pub async fn get_public_feed( + feed: &dyn FeedRepository, + viewer_id: Option<&UserId>, + page: PageParams, +) -> Result, DomainError> { feed.public_feed(&page, viewer_id).await } -pub async fn get_user_feed(feed: &dyn FeedRepository, user_id: &UserId, page: PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { +pub async fn get_user_feed( + feed: &dyn FeedRepository, + user_id: &UserId, + page: PageParams, + viewer_id: Option<&UserId>, +) -> Result, DomainError> { feed.user_feed(user_id, &page, viewer_id).await } -pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { +pub async fn get_followers( + follows: &dyn FollowRepository, + user_id: &UserId, + page: PageParams, +) -> Result, DomainError> { follows.list_followers(user_id, &page).await } -pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result, DomainError> { +pub async fn get_following( + follows: &dyn FollowRepository, + user_id: &UserId, + page: PageParams, +) -> Result, DomainError> { follows.list_following(user_id, &page).await } -pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { +pub async fn get_by_tag( + feed: &dyn FeedRepository, + tag_name: &str, + page: PageParams, + viewer_id: Option<&UserId>, +) -> Result, DomainError> { feed.tag_feed(tag_name, &page, viewer_id).await } -pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result, DomainError> { +pub async fn search( + feed: &dyn FeedRepository, + query: &str, + page: PageParams, + viewer_id: Option<&UserId>, +) -> Result, DomainError> { feed.search(query, &page, viewer_id).await } @@ -41,6 +73,9 @@ pub async fn list_users(users: &dyn UserRepository) -> Result, users.list_with_stats().await } -pub async fn get_popular_tags(tags: &dyn TagRepository, limit: usize) -> Result, DomainError> { +pub async fn get_popular_tags( + tags: &dyn TagRepository, + limit: usize, +) -> Result, DomainError> { tags.popular_tags(limit).await } diff --git a/crates/application/src/use_cases/profile.rs b/crates/application/src/use_cases/profile.rs index fdb3be3..773a043 100644 --- a/crates/application/src/use_cases/profile.rs +++ b/crates/application/src/use_cases/profile.rs @@ -6,12 +6,21 @@ use domain::{ }; pub async fn get_user(users: &dyn UserRepository, user_id: &UserId) -> Result { - users.find_by_id(user_id).await?.ok_or(DomainError::NotFound) + users + .find_by_id(user_id) + .await? + .ok_or(DomainError::NotFound) } -pub async fn get_user_by_username(users: &dyn UserRepository, username: &str) -> Result { +pub async fn get_user_by_username( + users: &dyn UserRepository, + username: &str, +) -> Result { let username = Username::new(username).map_err(|_| DomainError::NotFound)?; - users.find_by_username(&username).await?.ok_or(DomainError::NotFound) + users + .find_by_username(&username) + .await? + .ok_or(DomainError::NotFound) } pub async fn update_profile( @@ -23,16 +32,38 @@ pub async fn update_profile( header_url: Option, custom_css: Option, ) -> Result<(), DomainError> { - users.update_profile(user_id, display_name, bio, avatar_url, header_url, custom_css).await + users + .update_profile( + user_id, + display_name, + bio, + avatar_url, + header_url, + custom_css, + ) + .await } -pub async fn get_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId) -> Result, DomainError> { +pub async fn get_top_friends( + top_friends: &dyn TopFriendRepository, + user_id: &UserId, +) -> Result, DomainError> { top_friends.list_for_user(user_id).await } -pub async fn set_top_friends(top_friends: &dyn TopFriendRepository, user_id: &UserId, friend_ids: Vec) -> Result<(), DomainError> { - if friend_ids.len() > 8 { return Err(DomainError::InvalidInput("top friends: max 8".into())); } - let friends: Vec<(UserId, i16)> = friend_ids.into_iter().enumerate().map(|(i, id)| (id, (i + 1) as i16)).collect(); +pub async fn set_top_friends( + top_friends: &dyn TopFriendRepository, + user_id: &UserId, + friend_ids: Vec, +) -> Result<(), DomainError> { + if friend_ids.len() > 8 { + return Err(DomainError::InvalidInput("top friends: max 8".into())); + } + let friends: Vec<(UserId, i16)> = friend_ids + .into_iter() + .enumerate() + .map(|(i, id)| (id, (i + 1) as i16)) + .collect(); top_friends.set_top_friends(user_id, friends).await } @@ -71,11 +102,21 @@ mod tests { let f1 = UserId::new(); let f2 = UserId::new(); let f3 = UserId::new(); - set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()]).await.unwrap(); + set_top_friends(&store, &uid, vec![f1.clone(), f2.clone(), f3.clone()]) + .await + .unwrap(); let tf = store.top_friends.lock().unwrap(); assert_eq!(tf.len(), 3); - let pos_f1 = tf.iter().find(|t| t.friend_id == f1).map(|t| t.position).unwrap(); - let pos_f2 = tf.iter().find(|t| t.friend_id == f2).map(|t| t.position).unwrap(); + let pos_f1 = tf + .iter() + .find(|t| t.friend_id == f1) + .map(|t| t.position) + .unwrap(); + let pos_f2 = tf + .iter() + .find(|t| t.friend_id == f2) + .map(|t| t.position) + .unwrap(); assert!(pos_f1 < pos_f2, "f1 should come before f2"); } diff --git a/crates/application/src/use_cases/social.rs b/crates/application/src/use_cases/social.rs index 0486896..e801ea3 100644 --- a/crates/application/src/use_cases/social.rs +++ b/crates/application/src/use_cases/social.rs @@ -7,63 +7,185 @@ use domain::{ value_objects::{BoostId, LikeId, ThoughtId, UserId}, }; -pub async fn like_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let like = Like { id: LikeId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; +pub async fn like_thought( + likes: &dyn LikeRepository, + events: &dyn EventPublisher, + user_id: &UserId, + thought_id: &ThoughtId, +) -> Result<(), DomainError> { + let like = Like { + id: LikeId::new(), + user_id: user_id.clone(), + thought_id: thought_id.clone(), + ap_id: None, + created_at: Utc::now(), + }; likes.save(&like).await?; - events.publish(&DomainEvent::LikeAdded { like_id: like.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; + events + .publish(&DomainEvent::LikeAdded { + like_id: like.id, + user_id: user_id.clone(), + thought_id: thought_id.clone(), + }) + .await?; Ok(()) } -pub async fn unlike_thought(likes: &dyn LikeRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { +pub async fn unlike_thought( + likes: &dyn LikeRepository, + events: &dyn EventPublisher, + user_id: &UserId, + thought_id: &ThoughtId, +) -> Result<(), DomainError> { likes.delete(user_id, thought_id).await?; - events.publish(&DomainEvent::LikeRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; + events + .publish(&DomainEvent::LikeRemoved { + user_id: user_id.clone(), + thought_id: thought_id.clone(), + }) + .await?; Ok(()) } -pub async fn boost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { - let boost = Boost { id: BoostId::new(), user_id: user_id.clone(), thought_id: thought_id.clone(), ap_id: None, created_at: Utc::now() }; +pub async fn boost_thought( + boosts: &dyn BoostRepository, + events: &dyn EventPublisher, + user_id: &UserId, + thought_id: &ThoughtId, +) -> Result<(), DomainError> { + let boost = Boost { + id: BoostId::new(), + user_id: user_id.clone(), + thought_id: thought_id.clone(), + ap_id: None, + created_at: Utc::now(), + }; boosts.save(&boost).await?; - events.publish(&DomainEvent::BoostAdded { boost_id: boost.id, user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; + events + .publish(&DomainEvent::BoostAdded { + boost_id: boost.id, + user_id: user_id.clone(), + thought_id: thought_id.clone(), + }) + .await?; Ok(()) } -pub async fn unboost_thought(boosts: &dyn BoostRepository, events: &dyn EventPublisher, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError> { +pub async fn unboost_thought( + boosts: &dyn BoostRepository, + events: &dyn EventPublisher, + user_id: &UserId, + thought_id: &ThoughtId, +) -> Result<(), DomainError> { boosts.delete(user_id, thought_id).await?; - events.publish(&DomainEvent::BoostRemoved { user_id: user_id.clone(), thought_id: thought_id.clone() }).await?; + events + .publish(&DomainEvent::BoostRemoved { + user_id: user_id.clone(), + thought_id: thought_id.clone(), + }) + .await?; Ok(()) } -pub async fn follow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - if follower_id == following_id { return Err(DomainError::InvalidInput("cannot follow yourself".into())); } - let follow = Follow { follower_id: follower_id.clone(), following_id: following_id.clone(), state: FollowState::Accepted, ap_id: None, created_at: Utc::now() }; +pub async fn follow_user( + follows: &dyn FollowRepository, + events: &dyn EventPublisher, + follower_id: &UserId, + following_id: &UserId, +) -> Result<(), DomainError> { + if follower_id == following_id { + return Err(DomainError::InvalidInput("cannot follow yourself".into())); + } + let follow = Follow { + follower_id: follower_id.clone(), + following_id: following_id.clone(), + state: FollowState::Accepted, + ap_id: None, + created_at: Utc::now(), + }; follows.save(&follow).await?; - events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; + events + .publish(&DomainEvent::FollowAccepted { + follower_id: follower_id.clone(), + following_id: following_id.clone(), + }) + .await?; Ok(()) } -pub async fn unfollow_user(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { +pub async fn unfollow_user( + follows: &dyn FollowRepository, + events: &dyn EventPublisher, + follower_id: &UserId, + following_id: &UserId, +) -> Result<(), DomainError> { follows.delete(follower_id, following_id).await?; - events.publish(&DomainEvent::Unfollowed { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; + events + .publish(&DomainEvent::Unfollowed { + follower_id: follower_id.clone(), + following_id: following_id.clone(), + }) + .await?; Ok(()) } -pub async fn accept_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - follows.update_state(follower_id, following_id, &FollowState::Accepted).await?; - events.publish(&DomainEvent::FollowAccepted { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; +pub async fn accept_follow( + follows: &dyn FollowRepository, + events: &dyn EventPublisher, + follower_id: &UserId, + following_id: &UserId, +) -> Result<(), DomainError> { + follows + .update_state(follower_id, following_id, &FollowState::Accepted) + .await?; + events + .publish(&DomainEvent::FollowAccepted { + follower_id: follower_id.clone(), + following_id: following_id.clone(), + }) + .await?; Ok(()) } -pub async fn reject_follow(follows: &dyn FollowRepository, events: &dyn EventPublisher, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError> { - follows.update_state(follower_id, following_id, &FollowState::Rejected).await?; - events.publish(&DomainEvent::FollowRejected { follower_id: follower_id.clone(), following_id: following_id.clone() }).await?; +pub async fn reject_follow( + follows: &dyn FollowRepository, + events: &dyn EventPublisher, + follower_id: &UserId, + following_id: &UserId, +) -> Result<(), DomainError> { + follows + .update_state(follower_id, following_id, &FollowState::Rejected) + .await?; + events + .publish(&DomainEvent::FollowRejected { + follower_id: follower_id.clone(), + following_id: following_id.clone(), + }) + .await?; Ok(()) } -pub async fn block_user(blocks: &dyn BlockRepository, events: &dyn EventPublisher, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - if blocker_id == blocked_id { return Err(DomainError::InvalidInput("cannot block yourself".into())); } - let block = Block { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone(), created_at: Utc::now() }; +pub async fn block_user( + blocks: &dyn BlockRepository, + events: &dyn EventPublisher, + blocker_id: &UserId, + blocked_id: &UserId, +) -> Result<(), DomainError> { + if blocker_id == blocked_id { + return Err(DomainError::InvalidInput("cannot block yourself".into())); + } + let block = Block { + blocker_id: blocker_id.clone(), + blocked_id: blocked_id.clone(), + created_at: Utc::now(), + }; blocks.save(&block).await?; - events.publish(&DomainEvent::UserBlocked { blocker_id: blocker_id.clone(), blocked_id: blocked_id.clone() }).await?; + events + .publish(&DomainEvent::UserBlocked { + blocker_id: blocker_id.clone(), + blocked_id: blocked_id.clone(), + }) + .await?; Ok(()) } @@ -74,10 +196,12 @@ pub async fn unblock_user( blocked_id: &UserId, ) -> Result<(), DomainError> { blocks.delete(blocker_id, blocked_id).await?; - events.publish(&DomainEvent::UserUnblocked { - blocker_id: blocker_id.clone(), - blocked_id: blocked_id.clone(), - }).await?; + events + .publish(&DomainEvent::UserUnblocked { + blocker_id: blocker_id.clone(), + blocked_id: blocked_id.clone(), + }) + .await?; Ok(()) } @@ -85,13 +209,21 @@ pub async fn unblock_user( mod tests { use super::*; use domain::{ - models::{thought::{Thought, Visibility}, user::User}, + models::{ + thought::{Thought, Visibility}, + user::User, + }, testing::TestStore, value_objects::*, }; fn user(name: &str) -> User { - User::new_local(UserId::new(), Username::new(name).unwrap(), Email::new(format!("{name}@ex.com")).unwrap(), PasswordHash("h".into())) + User::new_local( + UserId::new(), + Username::new(name).unwrap(), + Email::new(format!("{name}@ex.com")).unwrap(), + PasswordHash("h".into()), + ) } #[tokio::test] @@ -99,20 +231,35 @@ mod tests { let store = TestStore::default(); let alice = user("alice"); let tid = ThoughtId::new(); - store.thoughts.lock().unwrap().push(Thought::new_local(tid.clone(), alice.id.clone(), Content::new_local("hi").unwrap(), None, Visibility::Public, None, false)); + store.thoughts.lock().unwrap().push(Thought::new_local( + tid.clone(), + alice.id.clone(), + Content::new_local("hi").unwrap(), + None, + Visibility::Public, + None, + false, + )); like_thought(&store, &store, &alice.id, &tid).await.unwrap(); assert_eq!(store.likes.lock().unwrap().len(), 1); - unlike_thought(&store, &store, &alice.id, &tid).await.unwrap(); + unlike_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); assert!(store.likes.lock().unwrap().is_empty()); } #[tokio::test] async fn follow_and_unfollow() { let store = TestStore::default(); - let alice = user("alice"); let bob = user("bob"); - follow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); + let alice = user("alice"); + let bob = user("bob"); + follow_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); assert_eq!(store.follows.lock().unwrap().len(), 1); - unfollow_user(&store, &store, &alice.id, &bob.id).await.unwrap(); + unfollow_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); assert!(store.follows.lock().unwrap().is_empty()); } @@ -120,7 +267,9 @@ mod tests { async fn cannot_follow_self() { let store = TestStore::default(); let alice = user("alice"); - let err = follow_user(&store, &store, &alice.id, &alice.id).await.unwrap_err(); + let err = follow_user(&store, &store, &alice.id, &alice.id) + .await + .unwrap_err(); assert!(matches!(err, DomainError::InvalidInput(_))); } @@ -129,9 +278,13 @@ mod tests { let store = TestStore::default(); let alice = user("alice"); let bob = user("bob"); - block_user(&store, &store, &alice.id, &bob.id).await.unwrap(); + block_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); store.events.lock().unwrap().clear(); - unblock_user(&store, &store, &alice.id, &bob.id).await.unwrap(); + unblock_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); let events = store.events.lock().unwrap(); assert_eq!(events.len(), 1); assert!(matches!(events[0], DomainEvent::UserUnblocked { .. })); @@ -142,17 +295,23 @@ mod tests { let store = TestStore::default(); let alice = user("alice"); let bob = user("bob"); - block_user(&store, &store, &alice.id, &bob.id).await.unwrap(); + block_user(&store, &store, &alice.id, &bob.id) + .await + .unwrap(); assert_eq!(store.blocks.lock().unwrap().len(), 1); let events = store.events.lock().unwrap(); - assert!(events.iter().any(|e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id))); + assert!(events.iter().any( + |e| matches!(e, DomainEvent::UserBlocked { blocker_id, .. } if blocker_id == &alice.id) + )); } #[tokio::test] async fn cannot_block_self() { let store = TestStore::default(); let alice = user("alice"); - let err = block_user(&store, &store, &alice.id, &alice.id).await.unwrap_err(); + let err = block_user(&store, &store, &alice.id, &alice.id) + .await + .unwrap_err(); assert!(matches!(err, DomainError::InvalidInput(_))); } @@ -161,12 +320,20 @@ mod tests { let store = TestStore::default(); let alice = user("alice"); let tid = ThoughtId::new(); - boost_thought(&store, &store, &alice.id, &tid).await.unwrap(); + boost_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); assert_eq!(store.boosts.lock().unwrap().len(), 1); - unboost_thought(&store, &store, &alice.id, &tid).await.unwrap(); + unboost_thought(&store, &store, &alice.id, &tid) + .await + .unwrap(); assert!(store.boosts.lock().unwrap().is_empty()); let events = store.events.lock().unwrap(); - assert!(events.iter().any(|e| matches!(e, DomainEvent::BoostAdded { .. }))); - assert!(events.iter().any(|e| matches!(e, DomainEvent::BoostRemoved { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, DomainEvent::BoostAdded { .. }))); + assert!(events + .iter() + .any(|e| matches!(e, DomainEvent::BoostRemoved { .. }))); } } diff --git a/crates/application/src/use_cases/thoughts.rs b/crates/application/src/use_cases/thoughts.rs index c4105d6..6740323 100644 --- a/crates/application/src/use_cases/thoughts.rs +++ b/crates/application/src/use_cases/thoughts.rs @@ -21,7 +21,9 @@ pub struct CreateThoughtInput { pub content_warning: Option, pub sensitive: bool, } -pub struct CreateThoughtOutput { pub thought: Thought } +pub struct CreateThoughtOutput { + pub thought: Thought, +} pub async fn create_thought( thoughts: &dyn ThoughtRepository, @@ -30,18 +32,28 @@ pub async fn create_thought( input: CreateThoughtInput, ) -> Result { let content = Content::new_local(input.content)?; - let visibility = input.visibility.as_deref().map(Visibility::from_str).unwrap_or(Visibility::Public); + let visibility = input + .visibility + .as_deref() + .map(Visibility::from_str) + .unwrap_or(Visibility::Public); let thought = Thought::new_local( - ThoughtId::new(), input.user_id, - content, input.in_reply_to_id.clone(), - visibility, input.content_warning, input.sensitive, + ThoughtId::new(), + input.user_id, + content, + input.in_reply_to_id.clone(), + visibility, + input.content_warning, + input.sensitive, ); thoughts.save(&thought).await?; - events.publish(&DomainEvent::ThoughtCreated { - thought_id: thought.id.clone(), - user_id: thought.user_id.clone(), - in_reply_to_id: input.in_reply_to_id, - }).await?; + events + .publish(&DomainEvent::ThoughtCreated { + thought_id: thought.id.clone(), + user_id: thought.user_id.clone(), + in_reply_to_id: input.in_reply_to_id, + }) + .await?; Ok(CreateThoughtOutput { thought }) } @@ -51,10 +63,18 @@ pub async fn delete_thought( id: &ThoughtId, user_id: &UserId, ) -> Result<(), DomainError> { - let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; thoughts.delete(id, user_id).await?; - events.publish(&DomainEvent::ThoughtDeleted { thought_id: id.clone(), user_id: user_id.clone() }).await?; + events + .publish(&DomainEvent::ThoughtDeleted { + thought_id: id.clone(), + user_id: user_id.clone(), + }) + .await?; Ok(()) } @@ -65,19 +85,33 @@ pub async fn edit_thought( user_id: &UserId, new_content: String, ) -> Result<(), DomainError> { - let thought = thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound)?; + let thought = thoughts + .find_by_id(id) + .await? + .ok_or(DomainError::NotFound)?; require_owner(&thought, user_id)?; let content = Content::new_local(new_content)?; thoughts.update_content(id, &content).await?; - events.publish(&DomainEvent::ThoughtUpdated { thought_id: id.clone(), user_id: user_id.clone() }).await?; + events + .publish(&DomainEvent::ThoughtUpdated { + thought_id: id.clone(), + user_id: user_id.clone(), + }) + .await?; Ok(()) } -pub async fn get_thought(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result { +pub async fn get_thought( + thoughts: &dyn ThoughtRepository, + id: &ThoughtId, +) -> Result { thoughts.find_by_id(id).await?.ok_or(DomainError::NotFound) } -pub async fn get_thread(thoughts: &dyn ThoughtRepository, id: &ThoughtId) -> Result, DomainError> { +pub async fn get_thread( + thoughts: &dyn ThoughtRepository, + id: &ThoughtId, +) -> Result, DomainError> { thoughts.get_thread(id).await } @@ -91,18 +125,33 @@ mod tests { }; fn user() -> User { - User::new_local(UserId::new(), Username::new("alice").unwrap(), Email::new("alice@ex.com").unwrap(), PasswordHash("h".into())) + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) } fn input(uid: UserId) -> CreateThoughtInput { - CreateThoughtInput { user_id: uid, content: "hello".into(), in_reply_to_id: None, visibility: None, content_warning: None, sensitive: false } + CreateThoughtInput { + user_id: uid, + content: "hello".into(), + in_reply_to_id: None, + visibility: None, + content_warning: None, + sensitive: false, + } } #[tokio::test] async fn create_thought_saves_and_emits_event() { let store = TestStore::default(); - let u = user(); store.users.lock().unwrap().push(u.clone()); - let out = create_thought(&store, &store, &store, input(u.id.clone())).await.unwrap(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &store, input(u.id.clone())) + .await + .unwrap(); assert_eq!(out.thought.content.as_str(), "hello"); assert_eq!(store.events.lock().unwrap().len(), 1); } @@ -110,9 +159,14 @@ mod tests { #[tokio::test] async fn delete_own_thought_succeeds() { let store = TestStore::default(); - let u = user(); store.users.lock().unwrap().push(u.clone()); - let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())).await.unwrap(); - delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id).await.unwrap(); + let u = user(); + store.users.lock().unwrap().push(u.clone()); + let out = create_thought(&store, &store, &NoOpEventPublisher, input(u.id.clone())) + .await + .unwrap(); + delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &u.id) + .await + .unwrap(); assert!(store.thoughts.lock().unwrap().is_empty()); } @@ -120,10 +174,23 @@ mod tests { async fn delete_other_thought_returns_not_found() { let store = TestStore::default(); let alice = user(); - let bob = User::new_local(UserId::new(), Username::new("bob").unwrap(), Email::new("bob@ex.com").unwrap(), PasswordHash("h".into())); - store.users.lock().unwrap().extend([alice.clone(), bob.clone()]); - let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap(); - let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id).await.unwrap_err(); + let bob = User::new_local( + UserId::new(), + Username::new("bob").unwrap(), + Email::new("bob@ex.com").unwrap(), + PasswordHash("h".into()), + ); + store + .users + .lock() + .unwrap() + .extend([alice.clone(), bob.clone()]); + let out = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) + .await + .unwrap(); + let err = delete_thought(&store, &NoOpEventPublisher, &out.thought.id, &bob.id) + .await + .unwrap_err(); assert!(matches!(err, DomainError::NotFound)); } @@ -132,16 +199,29 @@ mod tests { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); - let out = create_thought(&store, &store, &store, input(alice.id.clone())).await.unwrap(); + let out = create_thought(&store, &store, &store, input(alice.id.clone())) + .await + .unwrap(); let tid = out.thought.id.clone(); - edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()).await.unwrap(); + edit_thought(&store, &store, &tid, &alice.id, "updated".to_string()) + .await + .unwrap(); - let saved = store.thoughts.lock().unwrap().iter().find(|t| t.id == tid).unwrap().clone(); + let saved = store + .thoughts + .lock() + .unwrap() + .iter() + .find(|t| t.id == tid) + .unwrap() + .clone(); assert_eq!(saved.content.as_str(), "updated"); let events = store.events.lock().unwrap(); - assert!(events.iter().any(|e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid))); + assert!(events.iter().any( + |e| matches!(e, DomainEvent::ThoughtUpdated { thought_id, .. } if thought_id == &tid) + )); } #[tokio::test] @@ -149,19 +229,32 @@ mod tests { let store = TestStore::default(); let alice = user(); store.users.lock().unwrap().push(alice.clone()); - let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())).await.unwrap().thought; + let original = create_thought(&store, &store, &NoOpEventPublisher, input(alice.id.clone())) + .await + .unwrap() + .thought; - create_thought(&store, &store, &NoOpEventPublisher, CreateThoughtInput { - user_id: alice.id.clone(), - content: "reply".into(), - in_reply_to_id: Some(original.id.clone()), - visibility: None, - content_warning: None, - sensitive: false, - }).await.unwrap(); + create_thought( + &store, + &store, + &NoOpEventPublisher, + CreateThoughtInput { + user_id: alice.id.clone(), + content: "reply".into(), + in_reply_to_id: Some(original.id.clone()), + visibility: None, + content_warning: None, + sensitive: false, + }, + ) + .await + .unwrap(); let thoughts = store.thoughts.lock().unwrap(); - let reply = thoughts.iter().find(|t| t.content.as_str() == "reply").unwrap(); + let reply = thoughts + .iter() + .find(|t| t.content.as_str() == "reply") + .unwrap(); assert_eq!(reply.in_reply_to_id, Some(original.id.clone())); } } diff --git a/crates/bootstrap/src/config.rs b/crates/bootstrap/src/config.rs index 59a8ded..89141fc 100644 --- a/crates/bootstrap/src/config.rs +++ b/crates/bootstrap/src/config.rs @@ -17,12 +17,9 @@ impl Config { pub fn from_env() -> Self { dotenvy::dotenv().ok(); Self { - database_url: std::env::var("DATABASE_URL") - .expect("DATABASE_URL is required"), - jwt_secret: std::env::var("JWT_SECRET") - .expect("JWT_SECRET is required"), - base_url: std::env::var("BASE_URL") - .unwrap_or_else(|_| "http://localhost:3000".into()), + database_url: std::env::var("DATABASE_URL").expect("DATABASE_URL is required"), + jwt_secret: std::env::var("JWT_SECRET").expect("JWT_SECRET is required"), + base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()), nats_url: std::env::var("NATS_URL").ok(), port: std::env::var("PORT") .ok() @@ -36,7 +33,9 @@ impl Config { .unwrap_or(true), host: std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".into()), cors_origins: std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "*".into()), - rate_limit: std::env::var("RATE_LIMIT").ok().and_then(|v| v.parse().ok()), + rate_limit: std::env::var("RATE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()), } } } diff --git a/crates/bootstrap/src/factory.rs b/crates/bootstrap/src/factory.rs index e217855..211a8e3 100644 --- a/crates/bootstrap/src/factory.rs +++ b/crates/bootstrap/src/factory.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; use async_trait::async_trait; use sqlx::PgPool; +use std::sync::Arc; use activitypub::ThoughtsObjectHandler; use activitypub_base::{ApFederationConfig, FederationData}; @@ -23,7 +23,9 @@ struct NoOpEventPublisher; #[async_trait] impl EventPublisher for NoOpEventPublisher { - async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } + async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } } pub async fn build(cfg: &Config) -> Infrastructure { @@ -58,7 +60,10 @@ pub async fn build(cfg: &Config) -> Infrastructure { // 3. ActivityPub federation let fed_data = FederationData::new( Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new(pool.clone(), cfg.base_url.clone())), + Arc::new(PostgresApUserRepository::new( + pool.clone(), + cfg.base_url.clone(), + )), Arc::new(ThoughtsObjectHandler::new( Arc::new(PgActivityPubRepository::new(pool.clone())), &cfg.base_url, @@ -74,22 +79,31 @@ pub async fn build(cfg: &Config) -> Infrastructure { // 4. Application state let state = AppState { - users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), - thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), - likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), - boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), - follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), - blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), - tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), - api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), - top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new(pool.clone())), - notifications: Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())), - remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new(pool.clone())), - feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), - search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), - auth: Arc::new(auth::JwtAuthService::new(cfg.jwt_secret.clone(), 86400 * 30)), - hasher: Arc::new(auth::Argon2PasswordHasher), - events: event_publisher, + users: Arc::new(postgres::user::PgUserRepository::new(pool.clone())), + thoughts: Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())), + likes: Arc::new(postgres::like::PgLikeRepository::new(pool.clone())), + boosts: Arc::new(postgres::boost::PgBoostRepository::new(pool.clone())), + follows: Arc::new(postgres::follow::PgFollowRepository::new(pool.clone())), + blocks: Arc::new(postgres::block::PgBlockRepository::new(pool.clone())), + tags: Arc::new(postgres::tag::PgTagRepository::new(pool.clone())), + api_keys: Arc::new(postgres::api_key::PgApiKeyRepository::new(pool.clone())), + top_friends: Arc::new(postgres::top_friend::PgTopFriendRepository::new( + pool.clone(), + )), + notifications: Arc::new(postgres::notification::PgNotificationRepository::new( + pool.clone(), + )), + remote_actors: Arc::new(postgres::remote_actor::PgRemoteActorRepository::new( + pool.clone(), + )), + feed: Arc::new(postgres::feed::PgFeedRepository::new(pool.clone())), + search: Arc::new(postgres_search::PgSearchRepository::new(pool.clone())), + auth: Arc::new(auth::JwtAuthService::new( + cfg.jwt_secret.clone(), + 86400 * 30, + )), + hasher: Arc::new(auth::Argon2PasswordHasher), + events: event_publisher, }; Infrastructure { state, fed_config } diff --git a/crates/bootstrap/src/main.rs b/crates/bootstrap/src/main.rs index 573a582..a1d9ae1 100644 --- a/crates/bootstrap/src/main.rs +++ b/crates/bootstrap/src/main.rs @@ -1,10 +1,6 @@ mod config; mod factory; -use std::net::SocketAddr; -use std::sync::Arc; -use tower_http::cors::{AllowOrigin, CorsLayer}; -use tracing_subscriber::EnvFilter; use activitypub_base::{ actor_handler::actor_handler, followers_handler::{followers_handler, following_handler}, @@ -13,6 +9,10 @@ use activitypub_base::{ outbox::outbox_handler, webfinger::webfinger_handler, }; +use std::net::SocketAddr; +use std::sync::Arc; +use tower_http::cors::{AllowOrigin, CorsLayer}; +use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() { @@ -41,14 +41,32 @@ async fn main() { }; let ap_router = axum::Router::new() - .route("/.well-known/webfinger", axum::routing::get(webfinger_handler)) - .route("/.well-known/nodeinfo", axum::routing::get(nodeinfo_well_known_handler)) + .route( + "/.well-known/webfinger", + axum::routing::get(webfinger_handler), + ) + .route( + "/.well-known/nodeinfo", + axum::routing::get(nodeinfo_well_known_handler), + ) .route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler)) .route("/users/{username}", axum::routing::get(actor_handler)) - .route("/users/{username}/inbox", axum::routing::post(inbox_handler)) - .route("/users/{username}/outbox", axum::routing::get(outbox_handler)) - .route("/users/{username}/followers", axum::routing::get(followers_handler)) - .route("/users/{username}/following", axum::routing::get(following_handler)) + .route( + "/users/{username}/inbox", + axum::routing::post(inbox_handler), + ) + .route( + "/users/{username}/outbox", + axum::routing::get(outbox_handler), + ) + .route( + "/users/{username}/followers", + axum::routing::get(followers_handler), + ) + .route( + "/users/{username}/following", + axum::routing::get(following_handler), + ) .layer(infra.fed_config.middleware()); let base = presentation::routes::router() @@ -77,8 +95,7 @@ async fn main() { let limiter = governor_conf.limiter().clone(); tokio::spawn(async move { - let mut interval = - tokio::time::interval(std::time::Duration::from_secs(60)); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); loop { interval.tick().await; limiter.retain_recent(); diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 95ef776..09970c2 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -1,30 +1,76 @@ -use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; +use crate::value_objects::{BoostId, LikeId, ThoughtId, UserId}; #[derive(Debug, Clone)] pub enum DomainEvent { - ThoughtCreated { thought_id: ThoughtId, user_id: UserId, in_reply_to_id: Option }, - ThoughtDeleted { thought_id: ThoughtId, user_id: UserId }, - ThoughtUpdated { thought_id: ThoughtId, user_id: UserId }, - LikeAdded { like_id: LikeId, user_id: UserId, thought_id: ThoughtId }, - LikeRemoved { user_id: UserId, thought_id: ThoughtId }, - BoostAdded { boost_id: BoostId, user_id: UserId, thought_id: ThoughtId }, - BoostRemoved { user_id: UserId, thought_id: ThoughtId }, - FollowRequested { follower_id: UserId, following_id: UserId }, - FollowAccepted { follower_id: UserId, following_id: UserId }, - FollowRejected { follower_id: UserId, following_id: UserId }, - Unfollowed { follower_id: UserId, following_id: UserId }, - UserBlocked { blocker_id: UserId, blocked_id: UserId }, - UserUnblocked { blocker_id: UserId, blocked_id: UserId }, - UserRegistered { user_id: UserId }, + ThoughtCreated { + thought_id: ThoughtId, + user_id: UserId, + in_reply_to_id: Option, + }, + ThoughtDeleted { + thought_id: ThoughtId, + user_id: UserId, + }, + ThoughtUpdated { + thought_id: ThoughtId, + user_id: UserId, + }, + LikeAdded { + like_id: LikeId, + user_id: UserId, + thought_id: ThoughtId, + }, + LikeRemoved { + user_id: UserId, + thought_id: ThoughtId, + }, + BoostAdded { + boost_id: BoostId, + user_id: UserId, + thought_id: ThoughtId, + }, + BoostRemoved { + user_id: UserId, + thought_id: ThoughtId, + }, + FollowRequested { + follower_id: UserId, + following_id: UserId, + }, + FollowAccepted { + follower_id: UserId, + following_id: UserId, + }, + FollowRejected { + follower_id: UserId, + following_id: UserId, + }, + Unfollowed { + follower_id: UserId, + following_id: UserId, + }, + UserBlocked { + blocker_id: UserId, + blocked_id: UserId, + }, + UserUnblocked { + blocker_id: UserId, + blocked_id: UserId, + }, + UserRegistered { + user_id: UserId, + }, } pub struct EventEnvelope { pub event: DomainEvent, - pub ack: Box, + pub ack: Box, pub nack: Box, } impl std::fmt::Debug for EventEnvelope { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("EventEnvelope").field("event", &self.event).finish() + f.debug_struct("EventEnvelope") + .field("event", &self.event) + .finish() } } diff --git a/crates/domain/src/models/api_key.rs b/crates/domain/src/models/api_key.rs index 101029c..7a19aa6 100644 --- a/crates/domain/src/models/api_key.rs +++ b/crates/domain/src/models/api_key.rs @@ -1,5 +1,5 @@ -use chrono::{DateTime, Utc}; use crate::value_objects::{ApiKeyId, UserId}; +use chrono::{DateTime, Utc}; #[derive(Debug, Clone)] pub struct ApiKey { diff --git a/crates/domain/src/models/feed.rs b/crates/domain/src/models/feed.rs index 8cc226f..bdb1066 100644 --- a/crates/domain/src/models/feed.rs +++ b/crates/domain/src/models/feed.rs @@ -1,4 +1,4 @@ -use crate::models::{user::User, thought::Thought}; +use crate::models::{thought::Thought, user::User}; use crate::value_objects::UserId; #[derive(Debug, Clone)] @@ -25,10 +25,17 @@ pub struct FeedEntry { } #[derive(Debug, Clone)] -pub struct PageParams { pub page: u64, pub per_page: u64 } +pub struct PageParams { + pub page: u64, + pub per_page: u64, +} impl PageParams { - pub fn offset(&self) -> i64 { ((self.page.saturating_sub(1)) * self.per_page) as i64 } - pub fn limit(&self) -> i64 { self.per_page as i64 } + pub fn offset(&self) -> i64 { + ((self.page.saturating_sub(1)) * self.per_page) as i64 + } + pub fn limit(&self) -> i64 { + self.per_page as i64 + } } #[derive(Debug, Clone)] diff --git a/crates/domain/src/models/notification.rs b/crates/domain/src/models/notification.rs index 6db91b8..82c82b5 100644 --- a/crates/domain/src/models/notification.rs +++ b/crates/domain/src/models/notification.rs @@ -1,14 +1,32 @@ +use crate::value_objects::{NotificationId, ThoughtId, UserId}; use chrono::{DateTime, Utc}; -use crate::value_objects::{NotificationId, UserId, ThoughtId}; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum NotificationType { Like, Boost, Follow, Mention, Reply } +pub enum NotificationType { + Like, + Boost, + Follow, + Mention, + Reply, +} impl NotificationType { pub fn from_str(s: &str) -> Self { - match s { "like" => Self::Like, "boost" => Self::Boost, "follow" => Self::Follow, "mention" => Self::Mention, _ => Self::Reply } + match s { + "like" => Self::Like, + "boost" => Self::Boost, + "follow" => Self::Follow, + "mention" => Self::Mention, + _ => Self::Reply, + } } pub fn as_str(&self) -> &str { - match self { Self::Like => "like", Self::Boost => "boost", Self::Follow => "follow", Self::Mention => "mention", Self::Reply => "reply" } + match self { + Self::Like => "like", + Self::Boost => "boost", + Self::Follow => "follow", + Self::Mention => "mention", + Self::Reply => "reply", + } } } diff --git a/crates/domain/src/models/social.rs b/crates/domain/src/models/social.rs index 82cb53d..15ee448 100644 --- a/crates/domain/src/models/social.rs +++ b/crates/domain/src/models/social.rs @@ -1,5 +1,5 @@ +use crate::value_objects::{BoostId, LikeId, ThoughtId, UserId}; use chrono::{DateTime, Utc}; -use crate::value_objects::{UserId, ThoughtId, LikeId, BoostId}; #[derive(Debug, Clone)] pub struct Like { @@ -20,13 +20,25 @@ pub struct Boost { } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum FollowState { Pending, Accepted, Rejected } +pub enum FollowState { + Pending, + Accepted, + Rejected, +} impl FollowState { pub fn from_str(s: &str) -> Self { - match s { "pending" => Self::Pending, "rejected" => Self::Rejected, _ => Self::Accepted } + match s { + "pending" => Self::Pending, + "rejected" => Self::Rejected, + _ => Self::Accepted, + } } pub fn as_str(&self) -> &str { - match self { Self::Pending => "pending", Self::Accepted => "accepted", Self::Rejected => "rejected" } + match self { + Self::Pending => "pending", + Self::Accepted => "accepted", + Self::Rejected => "rejected", + } } } diff --git a/crates/domain/src/models/tag.rs b/crates/domain/src/models/tag.rs index 9c78590..ccd9b7c 100644 --- a/crates/domain/src/models/tag.rs +++ b/crates/domain/src/models/tag.rs @@ -1,2 +1,5 @@ #[derive(Debug, Clone)] -pub struct Tag { pub id: i32, pub name: String } +pub struct Tag { + pub id: i32, + pub name: String, +} diff --git a/crates/domain/src/models/thought.rs b/crates/domain/src/models/thought.rs index 56a1869..4f8e42e 100644 --- a/crates/domain/src/models/thought.rs +++ b/crates/domain/src/models/thought.rs @@ -1,16 +1,29 @@ +use crate::value_objects::{Content, ThoughtId, UserId}; use chrono::{DateTime, Utc}; -use crate::value_objects::{ThoughtId, UserId, Content}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum Visibility { - Public, Followers, Unlisted, Direct, + Public, + Followers, + Unlisted, + Direct, } impl Visibility { pub fn from_str(s: &str) -> Self { - match s { "followers" => Self::Followers, "unlisted" => Self::Unlisted, "direct" => Self::Direct, _ => Self::Public } + match s { + "followers" => Self::Followers, + "unlisted" => Self::Unlisted, + "direct" => Self::Direct, + _ => Self::Public, + } } pub fn as_str(&self) -> &str { - match self { Self::Public => "public", Self::Followers => "followers", Self::Unlisted => "unlisted", Self::Direct => "direct" } + match self { + Self::Public => "public", + Self::Followers => "followers", + Self::Unlisted => "unlisted", + Self::Direct => "direct", + } } } @@ -32,14 +45,27 @@ pub struct Thought { impl Thought { pub fn new_local( - id: ThoughtId, user_id: UserId, content: Content, - in_reply_to_id: Option, visibility: Visibility, - content_warning: Option, sensitive: bool, + id: ThoughtId, + user_id: UserId, + content: Content, + in_reply_to_id: Option, + visibility: Visibility, + content_warning: Option, + sensitive: bool, ) -> Self { Self { - id, user_id, content, in_reply_to_id, in_reply_to_url: None, ap_id: None, - visibility, content_warning, sensitive, local: true, - created_at: Utc::now(), updated_at: None, + id, + user_id, + content, + in_reply_to_id, + in_reply_to_url: None, + ap_id: None, + visibility, + content_warning, + sensitive, + local: true, + created_at: Utc::now(), + updated_at: None, } } } diff --git a/crates/domain/src/models/top_friend.rs b/crates/domain/src/models/top_friend.rs index d0d3279..8603ee7 100644 --- a/crates/domain/src/models/top_friend.rs +++ b/crates/domain/src/models/top_friend.rs @@ -1,4 +1,8 @@ use crate::value_objects::UserId; #[derive(Debug, Clone)] -pub struct TopFriend { pub user_id: UserId, pub friend_id: UserId, pub position: i16 } +pub struct TopFriend { + pub user_id: UserId, + pub friend_id: UserId, + pub position: i16, +} diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index 0b19f98..b20f045 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -1,5 +1,5 @@ +use crate::value_objects::{Email, PasswordHash, UserId, Username}; use chrono::{DateTime, Utc}; -use crate::value_objects::{UserId, Username, Email, PasswordHash}; #[derive(Debug, Clone)] pub struct User { @@ -22,14 +22,30 @@ pub struct User { } impl User { - pub fn new_local(id: UserId, username: Username, email: Email, password_hash: PasswordHash) -> Self { + pub fn new_local( + id: UserId, + username: Username, + email: Email, + password_hash: PasswordHash, + ) -> Self { let now = Utc::now(); Self { - id, username, email, password_hash, - display_name: None, bio: None, avatar_url: None, header_url: None, - custom_css: None, local: true, ap_id: None, inbox_url: None, - public_key: None, private_key: None, - created_at: now, updated_at: now, + id, + username, + email, + password_hash, + display_name: None, + bio: None, + avatar_url: None, + header_url: None, + custom_css: None, + local: true, + ap_id: None, + inbox_url: None, + public_key: None, + private_key: None, + created_at: now, + updated_at: now, } } } diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 66a76a2..ba9a08d 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1,4 +1,3 @@ -use async_trait::async_trait; use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, @@ -13,10 +12,16 @@ use crate::{ top_friend::TopFriend, user::User, }, - value_objects::{ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username}, + value_objects::{ + ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username, + }, }; +use async_trait::async_trait; -pub struct GeneratedToken { pub token: String, pub user_id: UserId } +pub struct GeneratedToken { + pub token: String, + pub user_id: UserId, +} #[async_trait] pub trait AuthService: Send + Sync { @@ -45,7 +50,15 @@ pub trait UserRepository: Send + Sync { async fn find_by_username(&self, username: &Username) -> Result, DomainError>; async fn find_by_email(&self, email: &Email) -> Result, DomainError>; async fn save(&self, user: &User) -> Result<(), DomainError>; - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError>; + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError>; async fn list_with_stats(&self) -> Result, DomainError>; async fn count(&self) -> Result; } @@ -57,14 +70,22 @@ pub trait ThoughtRepository: Send + Sync { async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>; async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>; async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError>; - async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; + async fn list_by_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; } #[async_trait] pub trait LikeRepository: Send + Sync { async fn save(&self, like: &Like) -> Result<(), DomainError>; async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError>; async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; } @@ -72,7 +93,11 @@ pub trait LikeRepository: Send + Sync { pub trait BoostRepository: Send + Sync { async fn save(&self, boost: &Boost) -> Result<(), DomainError>; async fn delete(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result<(), DomainError>; - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError>; + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError>; async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result; } @@ -80,11 +105,31 @@ pub trait BoostRepository: Send + Sync { pub trait FollowRepository: Send + Sync { async fn save(&self, follow: &Follow) -> Result<(), DomainError>; async fn delete(&self, follower_id: &UserId, following_id: &UserId) -> Result<(), DomainError>; - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError>; - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError>; - async fn list_followers(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; - async fn list_following(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError>; + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError>; + async fn update_state( + &self, + follower_id: &UserId, + following_id: &UserId, + state: &FollowState, + ) -> Result<(), DomainError>; + async fn list_followers( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + async fn list_following( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; + async fn get_accepted_following_ids( + &self, + user_id: &UserId, + ) -> Result, DomainError>; } #[async_trait] @@ -97,10 +142,18 @@ pub trait BlockRepository: Send + Sync { #[async_trait] pub trait TagRepository: Send + Sync { async fn find_or_create(&self, name: &str) -> Result; - async fn attach_to_thought(&self, thought_id: &ThoughtId, tag_id: i32) -> Result<(), DomainError>; + async fn attach_to_thought( + &self, + thought_id: &ThoughtId, + tag_id: i32, + ) -> Result<(), DomainError>; async fn detach_from_thought(&self, thought_id: &ThoughtId) -> Result<(), DomainError>; async fn list_for_thought(&self, thought_id: &ThoughtId) -> Result, DomainError>; - async fn list_thoughts_by_tag(&self, tag_name: &str, page: &PageParams) -> Result, DomainError>; + async fn list_thoughts_by_tag( + &self, + tag_name: &str, + page: &PageParams, + ) -> Result, DomainError>; /// Returns (tag_name, thought_count) pairs ordered by usage, most popular first. async fn popular_tags(&self, limit: usize) -> Result, DomainError>; } @@ -115,14 +168,22 @@ pub trait ApiKeyRepository: Send + Sync { #[async_trait] pub trait TopFriendRepository: Send + Sync { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError>; + async fn set_top_friends( + &self, + user_id: &UserId, + friends: Vec<(UserId, i16)>, + ) -> Result<(), DomainError>; async fn list_for_user(&self, user_id: &UserId) -> Result, DomainError>; } #[async_trait] pub trait NotificationRepository: Send + Sync { async fn save(&self, n: &Notification) -> Result<(), DomainError>; - async fn list_for_user(&self, user_id: &UserId, page: &PageParams) -> Result, DomainError>; + async fn list_for_user( + &self, + user_id: &UserId, + page: &PageParams, + ) -> Result, DomainError>; async fn mark_read(&self, id: &NotificationId, user_id: &UserId) -> Result<(), DomainError>; async fn mark_all_read(&self, user_id: &UserId) -> Result<(), DomainError>; } @@ -135,11 +196,35 @@ pub trait RemoteActorRepository: Send + Sync { #[async_trait] pub trait FeedRepository: Send + Sync { - async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; - async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result, DomainError>; + async fn home_feed( + &self, + following_ids: &[UserId], + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; + async fn public_feed( + &self, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; + async fn search( + &self, + query: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; + async fn tag_feed( + &self, + tag_name: &str, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; + async fn user_feed( + &self, + user_id: &UserId, + page: &PageParams, + viewer_id: Option<&UserId>, + ) -> Result, DomainError>; } #[async_trait] @@ -198,10 +283,7 @@ pub trait ActivityPubRepository: Send + Sync { /// Ensure a remote actor placeholder exists; create one if absent. /// Idempotent — safe to call multiple times with the same URL. - async fn intern_remote_actor( - &self, - actor_ap_url: &url::Url, - ) -> Result; + async fn intern_remote_actor(&self, actor_ap_url: &url::Url) -> Result; // ── Inbox processing (remote → local) ─────────────────────────── @@ -228,10 +310,7 @@ pub trait ActivityPubRepository: Send + Sync { async fn retract_note(&self, ap_id: &url::Url) -> Result<(), DomainError>; /// Remove all Notes from a remote actor (actor-level Delete/Tombstone). - async fn retract_actor_notes( - &self, - actor_ap_url: &url::Url, - ) -> Result<(), DomainError>; + async fn retract_actor_notes(&self, actor_ap_url: &url::Url) -> Result<(), DomainError>; // ── Node-level stats ───────────────────────────────────────────── diff --git a/crates/domain/src/testing.rs b/crates/domain/src/testing.rs index 503497f..39d7b47 100644 --- a/crates/domain/src/testing.rs +++ b/crates/domain/src/testing.rs @@ -1,7 +1,3 @@ -use std::sync::{Arc, Mutex}; -use async_trait::async_trait; -use chrono::Utc; -use url; use crate::{ errors::DomainError, events::DomainEvent, @@ -17,33 +13,58 @@ use crate::{ user::User, }, ports::*, - value_objects::{ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username}, + value_objects::{ + ApiKeyId, Content, Email, NotificationId, PasswordHash, ThoughtId, UserId, Username, + }, }; +use async_trait::async_trait; +use chrono::Utc; +use std::sync::{Arc, Mutex}; +use url; #[derive(Default, Clone)] pub struct TestStore { - pub users: Arc>>, - pub thoughts: Arc>>, - pub likes: Arc>>, - pub boosts: Arc>>, - pub follows: Arc>>, - pub blocks: Arc>>, - pub tags: Arc>>, - pub api_keys: Arc>>, - pub top_friends: Arc>>, + pub users: Arc>>, + pub thoughts: Arc>>, + pub likes: Arc>>, + pub boosts: Arc>>, + pub follows: Arc>>, + pub blocks: Arc>>, + pub tags: Arc>>, + pub api_keys: Arc>>, + pub top_friends: Arc>>, pub notifications: Arc>>, - pub events: Arc>>, + pub events: Arc>>, } -#[async_trait] impl UserRepository for TestStore { +#[async_trait] +impl UserRepository for TestStore { async fn find_by_id(&self, id: &UserId) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| &u.id == id).cloned()) + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| &u.id == id) + .cloned()) } async fn find_by_username(&self, username: &Username) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| u.username.as_str() == username.as_str()).cloned()) + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| u.username.as_str() == username.as_str()) + .cloned()) } async fn find_by_email(&self, email: &Email) -> Result, DomainError> { - Ok(self.users.lock().unwrap().iter().find(|u| u.email.as_str() == email.as_str()).cloned()) + Ok(self + .users + .lock() + .unwrap() + .iter() + .find(|u| u.email.as_str() == email.as_str()) + .cloned()) } async fn save(&self, user: &User) -> Result<(), DomainError> { let mut g = self.users.lock().unwrap(); @@ -51,8 +72,22 @@ pub struct TestStore { g.push(user.clone()); Ok(()) } - async fn update_profile(&self, user_id: &UserId, display_name: Option, bio: Option, avatar_url: Option, header_url: Option, custom_css: Option) -> Result<(), DomainError> { - if let Some(u) = self.users.lock().unwrap().iter_mut().find(|u| &u.id == user_id) { + async fn update_profile( + &self, + user_id: &UserId, + display_name: Option, + bio: Option, + avatar_url: Option, + header_url: Option, + custom_css: Option, + ) -> Result<(), DomainError> { + if let Some(u) = self + .users + .lock() + .unwrap() + .iter_mut() + .find(|u| &u.id == user_id) + { u.display_name = display_name; u.bio = bio; u.avatar_url = avatar_url; @@ -61,13 +96,22 @@ pub struct TestStore { } Ok(()) } - async fn list_with_stats(&self) -> Result, DomainError> { Ok(vec![]) } + async fn list_with_stats(&self) -> Result, DomainError> { + Ok(vec![]) + } async fn count(&self) -> Result { - Ok(self.users.lock().unwrap().iter().filter(|u| u.local).count() as i64) + Ok(self + .users + .lock() + .unwrap() + .iter() + .filter(|u| u.local) + .count() as i64) } } -#[async_trait] impl ThoughtRepository for TestStore { +#[async_trait] +impl ThoughtRepository for TestStore { async fn save(&self, t: &Thought) -> Result<(), DomainError> { let mut g = self.thoughts.lock().unwrap(); g.retain(|x| x.id != t.id); @@ -75,36 +119,67 @@ pub struct TestStore { Ok(()) } async fn find_by_id(&self, id: &ThoughtId) -> Result, DomainError> { - Ok(self.thoughts.lock().unwrap().iter().find(|t| &t.id == id).cloned()) + Ok(self + .thoughts + .lock() + .unwrap() + .iter() + .find(|t| &t.id == id) + .cloned()) } async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError> { let mut g = self.thoughts.lock().unwrap(); let before = g.len(); g.retain(|t| !(&t.id == id && &t.user_id == user_id)); - if g.len() == before { return Err(DomainError::NotFound); } + if g.len() == before { + return Err(DomainError::NotFound); + } Ok(()) } async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError> { - if let Some(t) = self.thoughts.lock().unwrap().iter_mut().find(|t| &t.id == id) { + if let Some(t) = self + .thoughts + .lock() + .unwrap() + .iter_mut() + .find(|t| &t.id == id) + { t.content = content.clone(); t.updated_at = Some(Utc::now()); } Ok(()) } async fn get_thread(&self, id: &ThoughtId) -> Result, DomainError> { - Ok(self.thoughts.lock().unwrap().iter() + Ok(self + .thoughts + .lock() + .unwrap() + .iter() .filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id) - .cloned().collect()) + .cloned() + .collect()) } - async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn list_by_user( + &self, + _user_id: &UserId, + _page: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } } -#[async_trait] impl LikeRepository for TestStore { +#[async_trait] +impl LikeRepository for TestStore { async fn save(&self, like: &Like) -> Result<(), DomainError> { let mut g = self.likes.lock().unwrap(); - if g.iter().any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) { + if g.iter() + .any(|l| l.user_id == like.user_id && l.thought_id == like.thought_id) + { return Err(DomainError::Conflict("already liked".into())); } g.push(like.clone()); @@ -114,21 +189,42 @@ pub struct TestStore { let mut g = self.likes.lock().unwrap(); let before = g.len(); g.retain(|l| !(&l.user_id == user_id && &l.thought_id == thought_id)); - if g.len() == before { return Err(DomainError::NotFound); } + if g.len() == before { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { - Ok(self.likes.lock().unwrap().iter().find(|l| &l.user_id == user_id && &l.thought_id == thought_id).cloned()) + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(self + .likes + .lock() + .unwrap() + .iter() + .find(|l| &l.user_id == user_id && &l.thought_id == thought_id) + .cloned()) } async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { - Ok(self.likes.lock().unwrap().iter().filter(|l| &l.thought_id == thought_id).count() as i64) + Ok(self + .likes + .lock() + .unwrap() + .iter() + .filter(|l| &l.thought_id == thought_id) + .count() as i64) } } -#[async_trait] impl BoostRepository for TestStore { +#[async_trait] +impl BoostRepository for TestStore { async fn save(&self, boost: &Boost) -> Result<(), DomainError> { let mut g = self.boosts.lock().unwrap(); - if g.iter().any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) { + if g.iter() + .any(|b| b.user_id == boost.user_id && b.thought_id == boost.thought_id) + { return Err(DomainError::Conflict("already boosted".into())); } g.push(boost.clone()); @@ -138,21 +234,42 @@ pub struct TestStore { let mut g = self.boosts.lock().unwrap(); let before = g.len(); g.retain(|b| !(&b.user_id == user_id && &b.thought_id == thought_id)); - if g.len() == before { return Err(DomainError::NotFound); } + if g.len() == before { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, user_id: &UserId, thought_id: &ThoughtId) -> Result, DomainError> { - Ok(self.boosts.lock().unwrap().iter().find(|b| &b.user_id == user_id && &b.thought_id == thought_id).cloned()) + async fn find( + &self, + user_id: &UserId, + thought_id: &ThoughtId, + ) -> Result, DomainError> { + Ok(self + .boosts + .lock() + .unwrap() + .iter() + .find(|b| &b.user_id == user_id && &b.thought_id == thought_id) + .cloned()) } async fn count_for_thought(&self, thought_id: &ThoughtId) -> Result { - Ok(self.boosts.lock().unwrap().iter().filter(|b| &b.thought_id == thought_id).count() as i64) + Ok(self + .boosts + .lock() + .unwrap() + .iter() + .filter(|b| &b.thought_id == thought_id) + .count() as i64) } } -#[async_trait] impl FollowRepository for TestStore { +#[async_trait] +impl FollowRepository for TestStore { async fn save(&self, follow: &Follow) -> Result<(), DomainError> { let mut g = self.follows.lock().unwrap(); - g.retain(|f| !(f.follower_id == follow.follower_id && f.following_id == follow.following_id)); + g.retain(|f| { + !(f.follower_id == follow.follower_id && f.following_id == follow.following_id) + }); g.push(follow.clone()); Ok(()) } @@ -160,160 +277,386 @@ pub struct TestStore { let mut g = self.follows.lock().unwrap(); let before = g.len(); g.retain(|f| !(&f.follower_id == follower_id && &f.following_id == following_id)); - if g.len() == before { return Err(DomainError::NotFound); } + if g.len() == before { + return Err(DomainError::NotFound); + } Ok(()) } - async fn find(&self, follower_id: &UserId, following_id: &UserId) -> Result, DomainError> { - Ok(self.follows.lock().unwrap().iter().find(|f| &f.follower_id == follower_id && &f.following_id == following_id).cloned()) + async fn find( + &self, + follower_id: &UserId, + following_id: &UserId, + ) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() + .find(|f| &f.follower_id == follower_id && &f.following_id == following_id) + .cloned()) } - async fn update_state(&self, follower_id: &UserId, following_id: &UserId, state: &FollowState) -> Result<(), DomainError> { - if let Some(f) = self.follows.lock().unwrap().iter_mut().find(|f| &f.follower_id == follower_id && &f.following_id == following_id) { + async fn update_state( + &self, + follower_id: &UserId, + following_id: &UserId, + state: &FollowState, + ) -> Result<(), DomainError> { + if let Some(f) = self + .follows + .lock() + .unwrap() + .iter_mut() + .find(|f| &f.follower_id == follower_id && &f.following_id == following_id) + { f.state = state.clone(); } Ok(()) } - async fn list_followers(&self, _user_id: &UserId, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn list_followers( + &self, + _user_id: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } - async fn list_following(&self, _user_id: &UserId, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn list_following( + &self, + _user_id: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } - async fn get_accepted_following_ids(&self, user_id: &UserId) -> Result, DomainError> { - Ok(self.follows.lock().unwrap().iter() + async fn get_accepted_following_ids( + &self, + user_id: &UserId, + ) -> Result, DomainError> { + Ok(self + .follows + .lock() + .unwrap() + .iter() .filter(|f| &f.follower_id == user_id && f.state == FollowState::Accepted) .map(|f| f.following_id.clone()) .collect()) } } -#[async_trait] impl BlockRepository for TestStore { +#[async_trait] +impl BlockRepository for TestStore { async fn save(&self, block: &Block) -> Result<(), DomainError> { self.blocks.lock().unwrap().push(block.clone()); Ok(()) } async fn delete(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result<(), DomainError> { - self.blocks.lock().unwrap().retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id)); + self.blocks + .lock() + .unwrap() + .retain(|b| !(&b.blocker_id == blocker_id && &b.blocked_id == blocked_id)); Ok(()) } async fn exists(&self, blocker_id: &UserId, blocked_id: &UserId) -> Result { - Ok(self.blocks.lock().unwrap().iter().any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id)) + Ok(self + .blocks + .lock() + .unwrap() + .iter() + .any(|b| &b.blocker_id == blocker_id && &b.blocked_id == blocked_id)) } } -#[async_trait] impl TagRepository for TestStore { +#[async_trait] +impl TagRepository for TestStore { async fn find_or_create(&self, name: &str) -> Result { let mut g = self.tags.lock().unwrap(); - if let Some(t) = g.iter().find(|t| t.name == name) { return Ok(t.clone()); } - let tag = Tag { id: g.len() as i32 + 1, name: name.to_string() }; + if let Some(t) = g.iter().find(|t| t.name == name) { + return Ok(t.clone()); + } + let tag = Tag { + id: g.len() as i32 + 1, + name: name.to_string(), + }; g.push(tag.clone()); Ok(tag) } - async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { Ok(()) } - async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { Ok(()) } - async fn list_for_thought(&self, _tid: &ThoughtId) -> Result, DomainError> { Ok(vec![]) } - async fn list_thoughts_by_tag(&self, _name: &str, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn attach_to_thought(&self, _tid: &ThoughtId, _tag_id: i32) -> Result<(), DomainError> { + Ok(()) + } + async fn detach_from_thought(&self, _tid: &ThoughtId) -> Result<(), DomainError> { + Ok(()) + } + async fn list_for_thought(&self, _tid: &ThoughtId) -> Result, DomainError> { + Ok(vec![]) + } + async fn list_thoughts_by_tag( + &self, + _name: &str, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } async fn popular_tags(&self, _limit: usize) -> Result, DomainError> { Ok(vec![]) } } -#[async_trait] impl ApiKeyRepository for TestStore { +#[async_trait] +impl ApiKeyRepository for TestStore { async fn save(&self, key: &ApiKey) -> Result<(), DomainError> { self.api_keys.lock().unwrap().push(key.clone()); Ok(()) } async fn find_by_hash(&self, hash: &str) -> Result, DomainError> { - Ok(self.api_keys.lock().unwrap().iter().find(|k| k.key_hash == hash).cloned()) + Ok(self + .api_keys + .lock() + .unwrap() + .iter() + .find(|k| k.key_hash == hash) + .cloned()) } async fn list_for_user(&self, uid: &UserId) -> Result, DomainError> { - Ok(self.api_keys.lock().unwrap().iter().filter(|k| &k.user_id == uid).cloned().collect()) + Ok(self + .api_keys + .lock() + .unwrap() + .iter() + .filter(|k| &k.user_id == uid) + .cloned() + .collect()) } async fn delete(&self, id: &ApiKeyId, uid: &UserId) -> Result<(), DomainError> { - self.api_keys.lock().unwrap().retain(|k| !(&k.id == id && &k.user_id == uid)); + self.api_keys + .lock() + .unwrap() + .retain(|k| !(&k.id == id && &k.user_id == uid)); Ok(()) } } -#[async_trait] impl TopFriendRepository for TestStore { - async fn set_top_friends(&self, user_id: &UserId, friends: Vec<(UserId, i16)>) -> Result<(), DomainError> { +#[async_trait] +impl TopFriendRepository for TestStore { + async fn set_top_friends( + &self, + user_id: &UserId, + friends: Vec<(UserId, i16)>, + ) -> Result<(), DomainError> { let mut g = self.top_friends.lock().unwrap(); g.retain(|tf| &tf.user_id != user_id); for (fid, pos) in friends { - g.push(TopFriend { user_id: user_id.clone(), friend_id: fid, position: pos }); + g.push(TopFriend { + user_id: user_id.clone(), + friend_id: fid, + position: pos, + }); } Ok(()) } - async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { Ok(vec![]) } + async fn list_for_user(&self, _uid: &UserId) -> Result, DomainError> { + Ok(vec![]) + } } -#[async_trait] impl NotificationRepository for TestStore { +#[async_trait] +impl NotificationRepository for TestStore { async fn save(&self, n: &Notification) -> Result<(), DomainError> { self.notifications.lock().unwrap().push(n.clone()); Ok(()) } - async fn list_for_user(&self, uid: &UserId, _p: &PageParams) -> Result, DomainError> { - let items: Vec<_> = self.notifications.lock().unwrap().iter().filter(|n| &n.user_id == uid).cloned().collect(); + async fn list_for_user( + &self, + uid: &UserId, + _p: &PageParams, + ) -> Result, DomainError> { + let items: Vec<_> = self + .notifications + .lock() + .unwrap() + .iter() + .filter(|n| &n.user_id == uid) + .cloned() + .collect(); let total = items.len() as i64; - Ok(Paginated { items, total, page: 1, per_page: 20 }) + Ok(Paginated { + items, + total, + page: 1, + per_page: 20, + }) } async fn mark_read(&self, id: &NotificationId, _uid: &UserId) -> Result<(), DomainError> { - if let Some(n) = self.notifications.lock().unwrap().iter_mut().find(|n| &n.id == id) { + if let Some(n) = self + .notifications + .lock() + .unwrap() + .iter_mut() + .find(|n| &n.id == id) + { n.read = true; } Ok(()) } async fn mark_all_read(&self, uid: &UserId) -> Result<(), DomainError> { - for n in self.notifications.lock().unwrap().iter_mut().filter(|n| &n.user_id == uid) { + for n in self + .notifications + .lock() + .unwrap() + .iter_mut() + .filter(|n| &n.user_id == uid) + { n.read = true; } Ok(()) } } -#[async_trait] impl RemoteActorRepository for TestStore { - async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { Ok(()) } - async fn find_by_url(&self, _url: &str) -> Result, DomainError> { Ok(None) } -} - -#[async_trait] impl FeedRepository for TestStore { - async fn home_feed(&self, _ids: &[UserId], _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) +#[async_trait] +impl RemoteActorRepository for TestStore { + async fn upsert(&self, _a: &RemoteActor) -> Result<(), DomainError> { + Ok(()) } - async fn public_feed(&self, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn search(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn tag_feed(&self, _tag_name: &str, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) - } - async fn user_feed(&self, _user_id: &UserId, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn find_by_url(&self, _url: &str) -> Result, DomainError> { + Ok(None) } } -#[async_trait] impl SearchPort for TestStore { - async fn search_thoughts(&self, _q: &str, _p: &PageParams, _v: Option<&UserId>) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) +#[async_trait] +impl FeedRepository for TestStore { + async fn home_feed( + &self, + _ids: &[UserId], + _p: &PageParams, + _v: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } - async fn search_users(&self, _q: &str, _p: &PageParams) -> Result, DomainError> { - Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 }) + async fn public_feed( + &self, + _p: &PageParams, + _v: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn search( + &self, + _q: &str, + _p: &PageParams, + _v: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn tag_feed( + &self, + _tag_name: &str, + _page: &PageParams, + _viewer_id: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn user_feed( + &self, + _user_id: &UserId, + _page: &PageParams, + _viewer_id: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) } } -#[async_trait] impl ActivityPubRepository for TestStore { - async fn outbox_entries_for_actor(&self, _uid: &UserId) -> Result, DomainError> { +#[async_trait] +impl SearchPort for TestStore { + async fn search_thoughts( + &self, + _q: &str, + _p: &PageParams, + _v: Option<&UserId>, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } + async fn search_users( + &self, + _q: &str, + _p: &PageParams, + ) -> Result, DomainError> { + Ok(Paginated { + items: vec![], + total: 0, + page: 1, + per_page: 20, + }) + } +} + +#[async_trait] +impl ActivityPubRepository for TestStore { + async fn outbox_entries_for_actor( + &self, + _uid: &UserId, + ) -> Result, DomainError> { Ok(vec![]) } - async fn outbox_page_for_actor(&self, _uid: &UserId, _before: Option>, _limit: usize) -> Result, DomainError> { + async fn outbox_page_for_actor( + &self, + _uid: &UserId, + _before: Option>, + _limit: usize, + ) -> Result, DomainError> { Ok(vec![]) } - async fn find_remote_actor_id(&self, actor_ap_url: &url::Url) -> Result, DomainError> { + async fn find_remote_actor_id( + &self, + actor_ap_url: &url::Url, + ) -> Result, DomainError> { let url = actor_ap_url.to_string(); - Ok(self.users.lock().unwrap().iter() + Ok(self + .users + .lock() + .unwrap() + .iter() .find(|u| u.ap_id.as_deref() == Some(&url)) .map(|u| u.id.clone())) } @@ -322,31 +665,68 @@ pub struct TestStore { return Ok(uid); } let uid = UserId::new(); - let handle = actor_ap_url.path().trim_start_matches('/').replace('/', "_"); + let handle = actor_ap_url + .path() + .trim_start_matches('/') + .replace('/', "_"); let user = crate::models::user::User { id: uid.clone(), username: Username::from_trusted(handle.clone()), email: Email::from_trusted(format!("{}@remote", uid)), password_hash: PasswordHash("".into()), - display_name: None, bio: None, avatar_url: None, header_url: None, - custom_css: None, local: false, + display_name: None, + bio: None, + avatar_url: None, + header_url: None, + custom_css: None, + local: false, ap_id: Some(actor_ap_url.to_string()), - inbox_url: None, public_key: None, private_key: None, - created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), + inbox_url: None, + public_key: None, + private_key: None, + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), }; self.users.lock().unwrap().push(user); Ok(uid) } - async fn accept_note(&self, _ap_id: &url::Url, _author_id: &UserId, _content: &str, _published: chrono::DateTime, _sensitive: bool, _content_warning: Option) -> Result<(), DomainError> { Ok(()) } - async fn apply_note_update(&self, _ap_id: &url::Url, _new_content: &str) -> Result<(), DomainError> { Ok(()) } - async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> { Ok(()) } - async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> { Ok(()) } + async fn accept_note( + &self, + _ap_id: &url::Url, + _author_id: &UserId, + _content: &str, + _published: chrono::DateTime, + _sensitive: bool, + _content_warning: Option, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn apply_note_update( + &self, + _ap_id: &url::Url, + _new_content: &str, + ) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_note(&self, _ap_id: &url::Url) -> Result<(), DomainError> { + Ok(()) + } + async fn retract_actor_notes(&self, _actor_ap_url: &url::Url) -> Result<(), DomainError> { + Ok(()) + } async fn count_local_notes(&self) -> Result { - Ok(self.thoughts.lock().unwrap().iter().filter(|t| t.local).count() as u64) + Ok(self + .thoughts + .lock() + .unwrap() + .iter() + .filter(|t| t.local) + .count() as u64) } } -#[async_trait] impl EventPublisher for TestStore { +#[async_trait] +impl EventPublisher for TestStore { async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> { self.events.lock().unwrap().push(event.clone()); Ok(()) @@ -354,8 +734,11 @@ pub struct TestStore { } pub struct NoOpEventPublisher; -#[async_trait] impl EventPublisher for NoOpEventPublisher { - async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { Ok(()) } +#[async_trait] +impl EventPublisher for NoOpEventPublisher { + async fn publish(&self, _e: &DomainEvent) -> Result<(), DomainError> { + Ok(()) + } } #[cfg(test)] @@ -366,7 +749,10 @@ mod ap_repo_tests { #[tokio::test] async fn test_store_outbox_returns_empty() { let store = TestStore::default(); - let result = store.outbox_entries_for_actor(&UserId::new()).await.unwrap(); + let result = store + .outbox_entries_for_actor(&UserId::new()) + .await + .unwrap(); assert!(result.is_empty()); } @@ -388,14 +774,33 @@ mod search_tests { #[tokio::test] async fn test_store_search_thoughts_returns_empty() { let store = TestStore::default(); - let result = store.search_thoughts("hello", &PageParams { page: 1, per_page: 20 }, None).await.unwrap(); + let result = store + .search_thoughts( + "hello", + &PageParams { + page: 1, + per_page: 20, + }, + None, + ) + .await + .unwrap(); assert_eq!(result.total, 0); } #[tokio::test] async fn test_store_search_users_returns_empty() { let store = TestStore::default(); - let result = store.search_users("alice", &PageParams { page: 1, per_page: 20 }).await.unwrap(); + let result = store + .search_users( + "alice", + &PageParams { + page: 1, + per_page: 20, + }, + ) + .await + .unwrap(); assert_eq!(result.total, 0); } } diff --git a/crates/domain/src/value_objects.rs b/crates/domain/src/value_objects.rs index 927b304..6998aa5 100644 --- a/crates/domain/src/value_objects.rs +++ b/crates/domain/src/value_objects.rs @@ -1,17 +1,25 @@ -use uuid::Uuid; use crate::errors::DomainError; +use uuid::Uuid; macro_rules! uuid_id { ($name:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct $name(Uuid); impl $name { - pub fn new() -> Self { Self(Uuid::new_v4()) } - pub fn from_uuid(u: Uuid) -> Self { Self(u) } - pub fn as_uuid(&self) -> Uuid { self.0 } + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn from_uuid(u: Uuid) -> Self { + Self(u) + } + pub fn as_uuid(&self) -> Uuid { + self.0 + } } impl Default for $name { - fn default() -> Self { Self::new() } + fn default() -> Self { + Self::new() + } } impl std::fmt::Display for $name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -37,15 +45,23 @@ impl Username { return Err(DomainError::InvalidInput("username: 1-32 chars".into())); } if !s.chars().all(|c| c.is_alphanumeric() || c == '_') { - return Err(DomainError::InvalidInput("username: alphanumeric or underscore only".into())); + return Err(DomainError::InvalidInput( + "username: alphanumeric or underscore only".into(), + )); } Ok(Self(s)) } - pub fn from_trusted(s: String) -> Self { Self(s) } - pub fn as_str(&self) -> &str { &self.0 } + pub fn from_trusted(s: String) -> Self { + Self(s) + } + pub fn as_str(&self) -> &str { + &self.0 + } } impl std::fmt::Display for Username { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -58,8 +74,12 @@ impl Email { } Ok(Self(s)) } - pub fn from_trusted(s: String) -> Self { Self(s) } - pub fn as_str(&self) -> &str { &self.0 } + pub fn from_trusted(s: String) -> Self { + Self(s) + } + pub fn as_str(&self) -> &str { + &self.0 + } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -75,11 +95,17 @@ impl Content { } Ok(Self(s)) } - pub fn new_remote(s: impl Into) -> Self { Self(s.into()) } - pub fn as_str(&self) -> &str { &self.0 } + pub fn new_remote(s: impl Into) -> Self { + Self(s.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } } impl std::fmt::Display for Content { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.0) } + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } } #[cfg(test)] diff --git a/crates/presentation/src/errors.rs b/crates/presentation/src/errors.rs index f4c273d..9b320bf 100644 --- a/crates/presentation/src/errors.rs +++ b/crates/presentation/src/errors.rs @@ -1,6 +1,10 @@ -use axum::{http::StatusCode, response::{IntoResponse, Response}, Json}; -use domain::errors::DomainError; use api_types::responses::ErrorResponse; +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use domain::errors::DomainError; pub enum ApiError { Domain(DomainError), @@ -9,20 +13,27 @@ pub enum ApiError { } impl From for ApiError { - fn from(e: DomainError) -> Self { Self::Domain(e) } + fn from(e: DomainError) -> Self { + Self::Domain(e) + } } impl IntoResponse for ApiError { fn into_response(self) -> Response { let (status, msg) = match self { - Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()), - Self::Domain(DomainError::Unauthorized) => (StatusCode::UNAUTHORIZED, "unauthorized".into()), - Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()), - Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m), - Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m), - Self::Domain(DomainError::Internal(_)) => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".into()), - Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), - Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + Self::Domain(DomainError::NotFound) => (StatusCode::NOT_FOUND, "not found".into()), + Self::Domain(DomainError::Unauthorized) => { + (StatusCode::UNAUTHORIZED, "unauthorized".into()) + } + Self::Domain(DomainError::Forbidden) => (StatusCode::FORBIDDEN, "forbidden".into()), + Self::Domain(DomainError::Conflict(m)) => (StatusCode::CONFLICT, m), + Self::Domain(DomainError::InvalidInput(m)) => (StatusCode::UNPROCESSABLE_ENTITY, m), + Self::Domain(DomainError::Internal(_)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal server error".into(), + ), + Self::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()), + Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), }; (status, Json(ErrorResponse { error: msg })).into_response() } diff --git a/crates/presentation/src/extractors.rs b/crates/presentation/src/extractors.rs index fc7b04e..5de0dd2 100644 --- a/crates/presentation/src/extractors.rs +++ b/crates/presentation/src/extractors.rs @@ -1,6 +1,6 @@ +use crate::{errors::ApiError, state::AppState}; use axum::{extract::FromRequestParts, http::request::Parts}; use domain::value_objects::UserId; -use crate::{errors::ApiError, state::AppState}; pub struct AuthUser(pub UserId); pub struct OptionalAuthUser(pub Option); @@ -8,7 +8,8 @@ pub struct OptionalAuthUser(pub Option); impl FromRequestParts for AuthUser { type Rejection = ApiError; async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { - extract_user_id(parts, state).await? + extract_user_id(parts, state) + .await? .ok_or(ApiError::Unauthorized) .map(AuthUser) } @@ -25,7 +26,11 @@ async fn extract_user_id(parts: &mut Parts, state: &AppState) -> Result)), security(("bearer_auth" = [])))] -pub async fn get_api_keys(State(s): State, AuthUser(uid): AuthUser) -> Result>, ApiError> { +pub async fn get_api_keys( + State(s): State, + AuthUser(uid): AuthUser, +) -> Result>, ApiError> { let keys = list_api_keys(&*s.api_keys, &uid).await?; - Ok(Json(keys.into_iter().map(|k| ApiKeyResponse { id: k.id.as_uuid(), name: k.name, created_at: k.created_at }).collect())) + Ok(Json( + keys.into_iter() + .map(|k| ApiKeyResponse { + id: k.id.as_uuid(), + name: k.name, + created_at: k.created_at, + }) + .collect(), + )) } #[utoipa::path(post, path = "/api-keys", request_body = CreateApiKeyRequest, responses((status = 200, description = "Created — raw key shown once", body = CreatedApiKeyResponse)), security(("bearer_auth" = [])))] -pub async fn post_api_key(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { +pub async fn post_api_key( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result, ApiError> { let (key, raw) = create_api_key(&*s.api_keys, &uid, body.name).await?; - Ok(Json(serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }))) + Ok(Json( + serde_json::json!({ "id": key.id.as_uuid(), "name": key.name, "key": raw }), + )) } #[utoipa::path(delete, path = "/api-keys/{id}", params(("id" = uuid::Uuid, Path, description = "Key ID")), responses((status = 204, description = "Deleted")), security(("bearer_auth" = [])))] -pub async fn delete_api_key_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn delete_api_key_handler( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { delete_api_key(&*s.api_keys, &uid, &ApiKeyId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index 823a3d4..d3dfeab 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -1,7 +1,10 @@ -use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; -use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse, UserResponse}}; -use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use crate::{errors::ApiError, state::AppState}; +use api_types::{ + requests::{LoginRequest, RegisterRequest}, + responses::{AuthResponse, ErrorResponse, UserResponse}, +}; +use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { UserResponse { @@ -25,13 +28,26 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { (status = 422, description = "Invalid input", body = ErrorResponse), ) )] -pub async fn post_register(State(s): State, Json(body): Json) -> Result { - let out = register(&*s.users, &*s.hasher, &*s.auth, &*s.events, RegisterInput { - username: body.username, - email: body.email, - password: body.password, - }).await?; - let resp = AuthResponse { token: out.token, user: to_user_response(&out.user) }; +pub async fn post_register( + State(s): State, + Json(body): Json, +) -> Result { + let out = register( + &*s.users, + &*s.hasher, + &*s.auth, + &*s.events, + RegisterInput { + username: body.username, + email: body.email, + password: body.password, + }, + ) + .await?; + let resp = AuthResponse { + token: out.token, + user: to_user_response(&out.user), + }; Ok((StatusCode::CREATED, Json(resp))) } @@ -43,10 +59,22 @@ pub async fn post_register(State(s): State, Json(body): Json, Json(body): Json) -> Result { - let out = login(&*s.users, &*s.hasher, &*s.auth, LoginInput { - email: body.email, - password: body.password, - }).await?; - Ok(Json(AuthResponse { token: out.token, user: to_user_response(&out.user) })) +pub async fn post_login( + State(s): State, + Json(body): Json, +) -> Result { + let out = login( + &*s.users, + &*s.hasher, + &*s.auth, + LoginInput { + email: body.email, + password: body.password, + }, + ) + .await?; + Ok(Json(AuthResponse { + token: out.token, + user: to_user_response(&out.user), + })) } diff --git a/crates/presentation/src/handlers/feed.rs b/crates/presentation/src/handlers/feed.rs index a1f679e..cd195b3 100644 --- a/crates/presentation/src/handlers/feed.rs +++ b/crates/presentation/src/handlers/feed.rs @@ -1,11 +1,22 @@ -use axum::{extract::{Path, Query, State}, Json}; +use crate::{ + errors::ApiError, + extractors::{AuthUser, OptionalAuthUser}, + handlers::auth::to_user_response, + state::AppState, +}; use api_types::requests::{PaginationQuery, SearchQuery}; use api_types::responses::ThoughtResponse; -use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, get_user_feed, get_by_tag, get_popular_tags as uc_get_popular_tags}; -use application::use_cases::search::{search_thoughts, search_users}; -use domain::models::feed::PageParams; -use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState}; +use application::use_cases::feed::{ + get_by_tag, get_followers, get_following, get_home_feed, + get_popular_tags as uc_get_popular_tags, get_public_feed, get_user_feed, +}; use application::use_cases::profile::get_user_by_username; +use application::use_cases::search::{search_thoughts, search_users}; +use axum::{ + extract::{Path, Query, State}, + Json, +}; +use domain::models::feed::PageParams; fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { ThoughtResponse { @@ -32,8 +43,15 @@ fn to_thought_response(e: &domain::models::feed::FeedEntry) -> ThoughtResponse { responses((status = 200, description = "Home feed")), security(("bearer_auth" = [])) )] -pub async fn home_feed(State(s): State, AuthUser(uid): AuthUser, Query(q): Query) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; +pub async fn home_feed( + State(s): State, + AuthUser(uid): AuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_home_feed(&*s.feed, &*s.follows, &uid, page).await?; Ok(Json(serde_json::json!({ "items": result.items.iter().map(to_thought_response).collect::>(), @@ -48,8 +66,15 @@ pub async fn home_feed(State(s): State, AuthUser(uid): AuthUser, Query params(PaginationQuery), responses((status = 200, description = "Public feed")) )] -pub async fn public_feed(State(s): State, OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; +pub async fn public_feed( + State(s): State, + OptionalAuthUser(viewer): OptionalAuthUser, + Query(q): Query, +) -> Result, ApiError> { + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_public_feed(&*s.feed, viewer.as_ref(), page).await?; Ok(Json(serde_json::json!({ "items": result.items.iter().map(to_thought_response).collect::>(), @@ -69,25 +94,53 @@ pub async fn search_handler( OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query, ) -> Result, ApiError> { - let page = PageParams { page: q.page.unwrap_or(1), per_page: q.per_page.unwrap_or(20) }; + let page = PageParams { + page: q.page.unwrap_or(1), + per_page: q.per_page.unwrap_or(20), + }; let query = q.q.trim().to_string(); let (thoughts_result, users_result) = tokio::join!( - search_thoughts(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }, viewer.as_ref()), - search_users(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }), + search_thoughts( + &*s.search, + &query, + PageParams { + page: page.page, + per_page: page.per_page + }, + viewer.as_ref() + ), + search_users( + &*s.search, + &query, + PageParams { + page: page.page, + per_page: page.per_page + } + ), ); - let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({ - "id": e.thought.id.as_uuid(), - "content": e.thought.content.as_str(), - "author": to_user_response(&e.author), - "like_count": e.like_count, - "boost_count": e.boost_count, - "reply_count": e.reply_count, - "created_at": e.thought.created_at, - })).collect::>(); + let thoughts = thoughts_result? + .items + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.thought.id.as_uuid(), + "content": e.thought.content.as_str(), + "author": to_user_response(&e.author), + "like_count": e.like_count, + "boost_count": e.boost_count, + "reply_count": e.reply_count, + "created_at": e.thought.created_at, + }) + }) + .collect::>(); - let users = users_result?.items.into_iter().map(|u| to_user_response(&u)).collect::>(); + let users = users_result? + .items + .into_iter() + .map(|u| to_user_response(&u)) + .collect::>(); Ok(Json(serde_json::json!({ "query": query, @@ -96,18 +149,36 @@ pub async fn search_handler( }))) } -pub async fn get_following_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { +pub async fn get_following_handler( + State(s): State, + Path(username): Path, + Query(q): Query, +) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_following(&*s.follows, &user.id, page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }))) + Ok(Json( + serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }), + )) } -pub async fn get_followers_handler(State(s): State, Path(username): Path, Query(q): Query) -> Result, ApiError> { +pub async fn get_followers_handler( + State(s): State, + Path(username): Path, + Query(q): Query, +) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_followers(&*s.follows, &user.id, page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }))) + Ok(Json( + serde_json::json!({ "total": result.total, "items": result.items.iter().map(to_user_response).collect::>() }), + )) } #[utoipa::path( @@ -125,7 +196,10 @@ pub async fn user_thoughts_handler( Query(q): Query, ) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; - let page = PageParams { page: q.page(), per_page: q.per_page() }; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_user_feed(&*s.feed, &user.id, page, viewer.as_ref()).await?; Ok(Json(serde_json::json!({ "total": result.total, @@ -139,7 +213,10 @@ pub async fn get_popular_tags( State(s): State, Query(params): Query>, ) -> Result, ApiError> { - let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20); + let limit: usize = params + .get("limit") + .and_then(|v| v.parse().ok()) + .unwrap_or(20); let tags = uc_get_popular_tags(&*s.tags, limit.min(100)).await?; Ok(Json(serde_json::json!({ "tags": tags.iter().map(|(name, count)| serde_json::json!({ @@ -163,7 +240,10 @@ pub async fn tag_thoughts_handler( OptionalAuthUser(viewer): OptionalAuthUser, Query(q): Query, ) -> Result, ApiError> { - let page = PageParams { page: q.page(), per_page: q.per_page() }; + let page = PageParams { + page: q.page(), + per_page: q.per_page(), + }; let result = get_by_tag(&*s.feed, &tag_name, page, viewer.as_ref()).await?; Ok(Json(serde_json::json!({ "tag": tag_name, diff --git a/crates/presentation/src/handlers/health.rs b/crates/presentation/src/handlers/health.rs index bcad996..de19c1a 100644 --- a/crates/presentation/src/handlers/health.rs +++ b/crates/presentation/src/handlers/health.rs @@ -1,5 +1,5 @@ -use axum::{extract::State, Json}; use crate::state::AppState; +use axum::{extract::State, Json}; #[utoipa::path(get, path = "/health", responses((status = 200, description = "Service health status")))] pub async fn health_handler(State(s): State) -> Json { diff --git a/crates/presentation/src/handlers/notifications.rs b/crates/presentation/src/handlers/notifications.rs index 91bd6a3..9222722 100644 --- a/crates/presentation/src/handlers/notifications.rs +++ b/crates/presentation/src/handlers/notifications.rs @@ -1,28 +1,46 @@ -use axum::{extract::{Path, State}, http::StatusCode, Json}; -use uuid::Uuid; -use domain::{models::feed::PageParams, value_objects::NotificationId}; -use application::use_cases::notifications::{ - list_notifications as uc_list_notifications, - mark_notification_read as uc_mark_notification_read, - mark_all_notifications_read, -}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; +use application::use_cases::notifications::{ + list_notifications as uc_list_notifications, mark_all_notifications_read, + mark_notification_read as uc_mark_notification_read, +}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use domain::{models::feed::PageParams, value_objects::NotificationId}; +use uuid::Uuid; #[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))] -pub async fn list_notifications(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { - let page = PageParams { page: 1, per_page: 20 }; +pub async fn list_notifications( + State(s): State, + AuthUser(uid): AuthUser, +) -> Result, ApiError> { + let page = PageParams { + page: 1, + per_page: 20, + }; let result = uc_list_notifications(&*s.notifications, &uid, page).await?; - Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }))) + Ok(Json( + serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() }), + )) } #[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))] -pub async fn mark_notification_read(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn mark_notification_read( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))] -pub async fn mark_all_read(State(s): State, AuthUser(uid): AuthUser) -> Result { +pub async fn mark_all_read( + State(s): State, + AuthUser(uid): AuthUser, +) -> Result { mark_all_notifications_read(&*s.notifications, &uid).await?; Ok(StatusCode::NO_CONTENT) } diff --git a/crates/presentation/src/handlers/social.rs b/crates/presentation/src/handlers/social.rs index 1b9ddda..262c0e6 100644 --- a/crates/presentation/src/handlers/social.rs +++ b/crates/presentation/src/handlers/social.rs @@ -1,61 +1,107 @@ -use axum::{extract::{Path, State}, http::StatusCode, Json}; -use uuid::Uuid; -use api_types::requests::SetTopFriendsRequest; -use application::use_cases::social::*; -use application::use_cases::profile::{get_top_friends, set_top_friends, get_user_by_username}; -use domain::value_objects::{ThoughtId, UserId}; use crate::{errors::ApiError, extractors::AuthUser, state::AppState}; +use api_types::requests::SetTopFriendsRequest; +use application::use_cases::profile::{get_top_friends, get_user_by_username, set_top_friends}; +use application::use_cases::social::*; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use domain::value_objects::{ThoughtId, UserId}; +use uuid::Uuid; #[utoipa::path(post, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Liked")), security(("bearer_auth" = [])))] -pub async fn post_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn post_like( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { like_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/thoughts/{id}/like", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unliked")), security(("bearer_auth" = [])))] -pub async fn delete_like(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn delete_like( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { unlike_thought(&*s.likes, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Boosted")), security(("bearer_auth" = [])))] -pub async fn post_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn post_boost( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { boost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/thoughts/{id}/boost", params(("id" = uuid::Uuid, Path, description = "Thought ID")), responses((status = 204, description = "Unboosted")), security(("bearer_auth" = [])))] -pub async fn delete_boost(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn delete_boost( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { unboost_thought(&*s.boosts, &*s.events, &uid, &ThoughtId::from_uuid(id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Following")), security(("bearer_auth" = [])))] -pub async fn post_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { +pub async fn post_follow( + State(s): State, + AuthUser(uid): AuthUser, + Path(target): Path, +) -> Result { follow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/users/{id}/follow", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unfollowed")), security(("bearer_auth" = [])))] -pub async fn delete_follow(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { +pub async fn delete_follow( + State(s): State, + AuthUser(uid): AuthUser, + Path(target): Path, +) -> Result { unfollow_user(&*s.follows, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(post, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Blocked")), security(("bearer_auth" = [])))] -pub async fn post_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { +pub async fn post_block( + State(s): State, + AuthUser(uid): AuthUser, + Path(target): Path, +) -> Result { block_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(delete, path = "/users/{id}/block", params(("id" = uuid::Uuid, Path, description = "User ID")), responses((status = 204, description = "Unblocked")), security(("bearer_auth" = [])))] -pub async fn delete_block(State(s): State, AuthUser(uid): AuthUser, Path(target): Path) -> Result { +pub async fn delete_block( + State(s): State, + AuthUser(uid): AuthUser, + Path(target): Path, +) -> Result { unblock_user(&*s.blocks, &*s.events, &uid, &UserId::from_uuid(target)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(put, path = "/users/me/top-friends", request_body = SetTopFriendsRequest, responses((status = 204, description = "Top friends updated")), security(("bearer_auth" = [])))] -pub async fn put_top_friends(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { +pub async fn put_top_friends( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { let ids: Vec = body.friend_ids.into_iter().map(UserId::from_uuid).collect(); set_top_friends(&*s.top_friends, &uid, ids).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path(get, path = "/users/{username}/top-friends", params(("username" = String, Path, description = "Username")), responses((status = 200, description = "Top friends list")))] -pub async fn get_top_friends_handler(State(s): State, Path(username): Path) -> Result, ApiError> { +pub async fn get_top_friends_handler( + State(s): State, + Path(username): Path, +) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; let friends = get_top_friends(&*s.top_friends, &user.id).await?; - let ids: Vec = friends.iter().map(|(tf, _)| tf.friend_id.as_uuid()).collect(); + let ids: Vec = friends + .iter() + .map(|(tf, _)| tf.friend_id.as_uuid()) + .collect(); Ok(Json(serde_json::json!({ "top_friends": ids }))) } diff --git a/crates/presentation/src/handlers/thoughts.rs b/crates/presentation/src/handlers/thoughts.rs index 115acb4..809f174 100644 --- a/crates/presentation/src/handlers/thoughts.rs +++ b/crates/presentation/src/handlers/thoughts.rs @@ -1,11 +1,32 @@ -use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, Json}; -use uuid::Uuid; -use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse}; -use application::use_cases::thoughts::{create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput}; +use crate::{ + errors::ApiError, + extractors::{AuthUser, OptionalAuthUser}, + handlers::auth::to_user_response, + state::AppState, +}; +use api_types::{ + requests::{CreateThoughtRequest, EditThoughtRequest}, + responses::ErrorResponse, +}; +use application::use_cases::thoughts::{ + create_thought, delete_thought, edit_thought, get_thought, get_thread, CreateThoughtInput, +}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; use domain::value_objects::ThoughtId; -use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState}; +use uuid::Uuid; -fn thought_to_json(t: &domain::models::thought::Thought, author: &domain::models::user::User, like_count: i64, boost_count: i64, reply_count: i64) -> serde_json::Value { +fn thought_to_json( + t: &domain::models::thought::Thought, + author: &domain::models::user::User, + like_count: i64, + boost_count: i64, + reply_count: i64, +) -> serde_json::Value { serde_json::json!({ "id": t.id.as_uuid(), "content": t.content.as_str(), @@ -32,18 +53,35 @@ fn thought_to_json(t: &domain::models::thought::Thought, author: &domain::models ), security(("bearer_auth" = [])) )] -pub async fn post_thought(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result { +pub async fn post_thought( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result { let in_reply_to = body.in_reply_to_id.map(ThoughtId::from_uuid); - let out = create_thought(&*s.thoughts, &*s.users, &*s.events, CreateThoughtInput { - user_id: uid.clone(), - content: body.content, - in_reply_to_id: in_reply_to, - visibility: body.visibility, - content_warning: body.content_warning, - sensitive: body.sensitive.unwrap_or(false), - }).await?; - let author = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; - Ok((StatusCode::CREATED, Json(thought_to_json(&out.thought, &author, 0, 0, 0)))) + let out = create_thought( + &*s.thoughts, + &*s.users, + &*s.events, + CreateThoughtInput { + user_id: uid.clone(), + content: body.content, + in_reply_to_id: in_reply_to, + visibility: body.visibility, + content_warning: body.content_warning, + sensitive: body.sensitive.unwrap_or(false), + }, + ) + .await?; + let author = s + .users + .find_by_id(&uid) + .await? + .ok_or(domain::errors::DomainError::NotFound)?; + Ok(( + StatusCode::CREATED, + Json(thought_to_json(&out.thought, &author, 0, 0, 0)), + )) } #[utoipa::path( @@ -54,9 +92,17 @@ pub async fn post_thought(State(s): State, AuthUser(uid): AuthUser, Js (status = 404, description = "Not found", body = ErrorResponse), ) )] -pub async fn get_thought_handler(State(s): State, Path(id): Path, OptionalAuthUser(_viewer): OptionalAuthUser) -> Result, ApiError> { +pub async fn get_thought_handler( + State(s): State, + Path(id): Path, + OptionalAuthUser(_viewer): OptionalAuthUser, +) -> Result, ApiError> { let thought = get_thought(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; - let author = s.users.find_by_id(&thought.user_id).await?.ok_or(domain::errors::DomainError::NotFound)?; + let author = s + .users + .find_by_id(&thought.user_id) + .await? + .ok_or(domain::errors::DomainError::NotFound)?; Ok(Json(thought_to_json(&thought, &author, 0, 0, 0))) } @@ -70,7 +116,11 @@ pub async fn get_thought_handler(State(s): State, Path(id): Path ), security(("bearer_auth" = [])) )] -pub async fn delete_thought_handler(State(s): State, AuthUser(uid): AuthUser, Path(id): Path) -> Result { +pub async fn delete_thought_handler( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, +) -> Result { delete_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid).await?; Ok(StatusCode::NO_CONTENT) } @@ -86,8 +136,20 @@ pub async fn delete_thought_handler(State(s): State, AuthUser(uid): Au ), security(("bearer_auth" = [])) )] -pub async fn patch_thought(State(s): State, AuthUser(uid): AuthUser, Path(id): Path, Json(body): Json) -> Result { - edit_thought(&*s.thoughts, &*s.events, &ThoughtId::from_uuid(id), &uid, body.content).await?; +pub async fn patch_thought( + State(s): State, + AuthUser(uid): AuthUser, + Path(id): Path, + Json(body): Json, +) -> Result { + edit_thought( + &*s.thoughts, + &*s.events, + &ThoughtId::from_uuid(id), + &uid, + body.content, + ) + .await?; Ok(StatusCode::NO_CONTENT) } @@ -98,7 +160,10 @@ pub async fn patch_thought(State(s): State, AuthUser(uid): AuthUser, P (status = 200, description = "Thread (root + replies)"), ) )] -pub async fn get_thread_handler(State(s): State, Path(id): Path) -> Result>, ApiError> { +pub async fn get_thread_handler( + State(s): State, + Path(id): Path, +) -> Result>, ApiError> { let thoughts = get_thread(&*s.thoughts, &ThoughtId::from_uuid(id)).await?; let mut items = Vec::new(); for t in &thoughts { diff --git a/crates/presentation/src/handlers/users.rs b/crates/presentation/src/handlers/users.rs index b21419b..16b95fd 100644 --- a/crates/presentation/src/handlers/users.rs +++ b/crates/presentation/src/handlers/users.rs @@ -1,9 +1,17 @@ -use axum::{extract::{Path, Query, State}, Json}; -use api_types::{requests::UpdateProfileRequest, responses::{ErrorResponse, UserResponse}}; +use crate::{ + errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState, +}; +use api_types::{ + requests::UpdateProfileRequest, + responses::{ErrorResponse, UserResponse}, +}; +use application::use_cases::feed::list_users; use application::use_cases::profile::{get_user_by_username, update_profile}; use application::use_cases::search::search_users; -use application::use_cases::feed::list_users; -use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState}; +use axum::{ + extract::{Path, Query, State}, + Json, +}; #[utoipa::path( get, path = "/users/{username}", @@ -13,7 +21,10 @@ use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_resp (status = 404, description = "User not found", body = ErrorResponse), ) )] -pub async fn get_user(State(s): State, Path(username): Path) -> Result, ApiError> { +pub async fn get_user( + State(s): State, + Path(username): Path, +) -> Result, ApiError> { let user = get_user_by_username(&*s.users, &username).await?; Ok(Json(to_user_response(&user))) } @@ -27,9 +38,26 @@ pub async fn get_user(State(s): State, Path(username): Path) - ), security(("bearer_auth" = [])) )] -pub async fn patch_profile(State(s): State, AuthUser(uid): AuthUser, Json(body): Json) -> Result, ApiError> { - update_profile(&*s.users, &uid, body.display_name, body.bio, body.avatar_url, body.header_url, body.custom_css).await?; - let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; +pub async fn patch_profile( + State(s): State, + AuthUser(uid): AuthUser, + Json(body): Json, +) -> Result, ApiError> { + update_profile( + &*s.users, + &uid, + body.display_name, + body.bio, + body.avatar_url, + body.header_url, + body.custom_css, + ) + .await?; + let user = s + .users + .find_by_id(&uid) + .await? + .ok_or(domain::errors::DomainError::NotFound)?; Ok(Json(to_user_response(&user))) } @@ -41,8 +69,15 @@ pub async fn patch_profile(State(s): State, AuthUser(uid): AuthUser, J ), security(("bearer_auth" = [])) )] -pub async fn get_me(State(s): State, AuthUser(uid): AuthUser) -> Result, ApiError> { - let user = s.users.find_by_id(&uid).await?.ok_or(domain::errors::DomainError::NotFound)?; +pub async fn get_me( + State(s): State, + AuthUser(uid): AuthUser, +) -> Result, ApiError> { + let user = s + .users + .find_by_id(&uid) + .await? + .ok_or(domain::errors::DomainError::NotFound)?; Ok(Json(to_user_response(&user))) } @@ -51,13 +86,23 @@ pub async fn get_users( Query(params): Query>, ) -> Result, ApiError> { use domain::models::feed::PageParams; - let page = params.get("page").and_then(|v| v.parse::().ok()).unwrap_or(1); - let per_page = params.get("per_page").and_then(|v| v.parse::().ok()).unwrap_or(20); + let page = params + .get("page") + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + let per_page = params + .get("per_page") + .and_then(|v| v.parse::().ok()) + .unwrap_or(20); let page_params = PageParams { page, per_page }; if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) { let result = search_users(&*s.search, q, page_params).await?; - let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect(); + let users: Vec<_> = result + .items + .iter() + .map(|u| crate::handlers::auth::to_user_response(u)) + .collect(); return Ok(Json(serde_json::json!({ "items": users, "total": result.total, "page": result.page, "per_page": result.per_page }))); @@ -66,18 +111,22 @@ pub async fn get_users( let all = list_users(&*s.users).await?; let total = all.len() as i64; let start = ((page - 1) * per_page) as usize; - let items: Vec<_> = all.into_iter() - .skip(start).take(per_page as usize) - .map(|u| serde_json::json!({ - "id": u.id.as_uuid(), - "username": u.username, - "display_name": u.display_name, - "avatar_url": u.avatar_url, - "bio": u.bio, - "thought_count": u.thought_count, - "follower_count": u.follower_count, - "following_count": u.following_count, - })) + let items: Vec<_> = all + .into_iter() + .skip(start) + .take(per_page as usize) + .map(|u| { + serde_json::json!({ + "id": u.id.as_uuid(), + "username": u.username, + "display_name": u.display_name, + "avatar_url": u.avatar_url, + "bio": u.bio, + "thought_count": u.thought_count, + "follower_count": u.follower_count, + "following_count": u.following_count, + }) + }) .collect(); Ok(Json(serde_json::json!({ "items": items, "total": total, "page": page, "per_page": per_page diff --git a/crates/presentation/src/openapi/api_keys.rs b/crates/presentation/src/openapi/api_keys.rs index bf75092..2b28a5f 100644 --- a/crates/presentation/src/openapi/api_keys.rs +++ b/crates/presentation/src/openapi/api_keys.rs @@ -1,5 +1,8 @@ +use api_types::{ + requests::CreateApiKeyRequest, + responses::{ApiKeyResponse, CreatedApiKeyResponse}, +}; use utoipa::OpenApi; -use api_types::{requests::CreateApiKeyRequest, responses::{ApiKeyResponse, CreatedApiKeyResponse}}; #[derive(OpenApi)] #[openapi( diff --git a/crates/presentation/src/openapi/auth.rs b/crates/presentation/src/openapi/auth.rs index 7aaa3fc..dbe252a 100644 --- a/crates/presentation/src/openapi/auth.rs +++ b/crates/presentation/src/openapi/auth.rs @@ -1,9 +1,15 @@ +use api_types::{ + requests::{LoginRequest, RegisterRequest}, + responses::{AuthResponse, ErrorResponse}, +}; use utoipa::OpenApi; -use api_types::{requests::{LoginRequest, RegisterRequest}, responses::{AuthResponse, ErrorResponse}}; #[derive(OpenApi)] #[openapi( - paths(crate::handlers::auth::post_register, crate::handlers::auth::post_login), + paths( + crate::handlers::auth::post_register, + crate::handlers::auth::post_login + ), components(schemas(RegisterRequest, LoginRequest, AuthResponse, ErrorResponse)) )] pub struct AuthDoc; diff --git a/crates/presentation/src/openapi/feed.rs b/crates/presentation/src/openapi/feed.rs index 90685d4..c8bf35c 100644 --- a/crates/presentation/src/openapi/feed.rs +++ b/crates/presentation/src/openapi/feed.rs @@ -1,13 +1,11 @@ use utoipa::OpenApi; #[derive(OpenApi)] -#[openapi( - paths( - crate::handlers::feed::home_feed, - crate::handlers::feed::public_feed, - crate::handlers::feed::search_handler, - crate::handlers::feed::user_thoughts_handler, - crate::handlers::feed::tag_thoughts_handler, - ), -)] +#[openapi(paths( + crate::handlers::feed::home_feed, + crate::handlers::feed::public_feed, + crate::handlers::feed::search_handler, + crate::handlers::feed::user_thoughts_handler, + crate::handlers::feed::tag_thoughts_handler, +))] pub struct FeedDoc; diff --git a/crates/presentation/src/openapi/mod.rs b/crates/presentation/src/openapi/mod.rs index 1819b29..a3203e1 100644 --- a/crates/presentation/src/openapi/mod.rs +++ b/crates/presentation/src/openapi/mod.rs @@ -9,8 +9,8 @@ mod users; use axum::Router; use utoipa::{ - Modify, OpenApi, openapi::security::{ApiKey, ApiKeyValue, Http, HttpAuthScheme, SecurityScheme}, + Modify, OpenApi, }; use utoipa_scalar::{Scalar, Servable}; use utoipa_swagger_ui::SwaggerUi; diff --git a/crates/presentation/src/openapi/social.rs b/crates/presentation/src/openapi/social.rs index 94ceda5..ab90680 100644 --- a/crates/presentation/src/openapi/social.rs +++ b/crates/presentation/src/openapi/social.rs @@ -1,5 +1,5 @@ -use utoipa::OpenApi; use api_types::requests::SetTopFriendsRequest; +use utoipa::OpenApi; #[derive(OpenApi)] #[openapi( diff --git a/crates/presentation/src/openapi/thoughts.rs b/crates/presentation/src/openapi/thoughts.rs index a355ab0..3796464 100644 --- a/crates/presentation/src/openapi/thoughts.rs +++ b/crates/presentation/src/openapi/thoughts.rs @@ -1,5 +1,8 @@ +use api_types::{ + requests::{CreateThoughtRequest, EditThoughtRequest}, + responses::ErrorResponse, +}; use utoipa::OpenApi; -use api_types::{requests::{CreateThoughtRequest, EditThoughtRequest}, responses::ErrorResponse}; #[derive(OpenApi)] #[openapi( diff --git a/crates/presentation/src/openapi/users.rs b/crates/presentation/src/openapi/users.rs index f897238..df6fd62 100644 --- a/crates/presentation/src/openapi/users.rs +++ b/crates/presentation/src/openapi/users.rs @@ -1,5 +1,8 @@ +use api_types::{ + requests::UpdateProfileRequest, + responses::{ErrorResponse, UserResponse}, +}; use utoipa::OpenApi; -use api_types::{requests::UpdateProfileRequest, responses::{UserResponse, ErrorResponse}}; #[derive(OpenApi)] #[openapi( diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index 4702be2..ea81184 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -1,8 +1,8 @@ +use crate::{handlers::*, openapi, state::AppState}; use axum::{ routing::{delete, get, post, put}, Router, }; -use crate::{handlers::*, openapi, state::AppState}; pub fn router() -> Router { let api_routes = Router::new() @@ -16,7 +16,10 @@ pub fn router() -> Router { .route("/users/count", get(users::get_user_count)) .route("/users/me", get(users::get_me).patch(users::patch_profile)) .route("/users/me/top-friends", put(social::put_top_friends)) - .route("/users/{username}/top-friends", get(social::get_top_friends_handler)) + .route( + "/users/{username}/top-friends", + get(social::get_top_friends_handler), + ) // follows & blocks (use {id} param) .route( "/users/{id}/follow", @@ -48,15 +51,30 @@ pub fn router() -> Router { .route("/feed", get(feed::home_feed)) .route("/feed/public", get(feed::public_feed)) .route("/search", get(feed::search_handler)) - .route("/users/{username}/follower-list", get(feed::get_followers_handler)) - .route("/users/{username}/following-list", get(feed::get_following_handler)) - .route("/users/{username}/thoughts", get(feed::user_thoughts_handler)) + .route( + "/users/{username}/follower-list", + get(feed::get_followers_handler), + ) + .route( + "/users/{username}/following-list", + get(feed::get_following_handler), + ) + .route( + "/users/{username}/thoughts", + get(feed::user_thoughts_handler), + ) .route("/tags/popular", get(feed::get_popular_tags)) .route("/tags/{name}", get(feed::tag_thoughts_handler)) // notifications .route("/notifications", get(notifications::list_notifications)) - .route("/notifications/read-all", post(notifications::mark_all_read)) - .route("/notifications/{id}/read", post(notifications::mark_notification_read)) + .route( + "/notifications/read-all", + post(notifications::mark_all_read), + ) + .route( + "/notifications/{id}/read", + post(notifications::mark_notification_read), + ) // api keys .route( "/api-keys", diff --git a/crates/presentation/src/state.rs b/crates/presentation/src/state.rs index c582001..c6a8c59 100644 --- a/crates/presentation/src/state.rs +++ b/crates/presentation/src/state.rs @@ -1,22 +1,22 @@ -use std::sync::Arc; use domain::ports::*; +use std::sync::Arc; #[derive(Clone)] pub struct AppState { - pub users: Arc, - pub thoughts: Arc, - pub likes: Arc, - pub boosts: Arc, - pub follows: Arc, - pub blocks: Arc, - pub tags: Arc, - pub api_keys: Arc, - pub top_friends: Arc, + pub users: Arc, + pub thoughts: Arc, + pub likes: Arc, + pub boosts: Arc, + pub follows: Arc, + pub blocks: Arc, + pub tags: Arc, + pub api_keys: Arc, + pub top_friends: Arc, pub notifications: Arc, pub remote_actors: Arc, - pub feed: Arc, - pub search: Arc, - pub auth: Arc, - pub hasher: Arc, - pub events: Arc, + pub feed: Arc, + pub search: Arc, + pub auth: Arc, + pub hasher: Arc, + pub events: Arc, } diff --git a/crates/worker/src/factory.rs b/crates/worker/src/factory.rs index 8c465de..cc7e52c 100644 --- a/crates/worker/src/factory.rs +++ b/crates/worker/src/factory.rs @@ -1,5 +1,5 @@ -use std::sync::Arc; use sqlx::PgPool; +use std::sync::Arc; use activitypub::ThoughtsObjectHandler; use activitypub_base::ActivityPubService; @@ -11,7 +11,7 @@ use crate::handlers::{FederationHandler, NotificationHandler}; pub struct WorkerHandlers { pub notification: NotificationHandler, - pub federation: FederationHandler, + pub federation: FederationHandler, } pub async fn build( @@ -27,15 +27,20 @@ pub async fn build( .expect("DB connect failed"); // Repos - let thoughts = Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())); - let users = Arc::new(postgres::user::PgUserRepository::new(pool.clone())); - let notifications = Arc::new(postgres::notification::PgNotificationRepository::new(pool.clone())); + let thoughts = Arc::new(postgres::thought::PgThoughtRepository::new(pool.clone())); + let users = Arc::new(postgres::user::PgUserRepository::new(pool.clone())); + let notifications = Arc::new(postgres::notification::PgNotificationRepository::new( + pool.clone(), + )); // ActivityPub service (for federation fan-out) let ap_service: Arc = Arc::new( ActivityPubService::new( Arc::new(PostgresFederationRepository::new(pool.clone())), - Arc::new(PostgresApUserRepository::new(pool.clone(), base_url.to_string())), + Arc::new(PostgresApUserRepository::new( + pool.clone(), + base_url.to_string(), + )), Arc::new(ThoughtsObjectHandler::new( Arc::new(PgActivityPubRepository::new(pool.clone())), base_url, @@ -64,17 +69,20 @@ pub async fn build( // Thin handlers let handlers = WorkerHandlers { - notification: NotificationHandler { service: notification_svc }, - federation: FederationHandler { service: federation_svc }, + notification: NotificationHandler { + service: notification_svc, + }, + federation: FederationHandler { + service: federation_svc, + }, }; // NATS consumer let nats_client = async_nats::connect(nats_url) .await .expect("NATS connect failed"); - let consumer = event_transport::EventConsumerAdapter::new( - nats::NatsMessageSource::new(nats_client), - ); + let consumer = + event_transport::EventConsumerAdapter::new(nats::NatsMessageSource::new(nats_client)); (consumer, handlers) } diff --git a/crates/worker/src/handlers.rs b/crates/worker/src/handlers.rs index cb64d8d..2adb2a4 100644 --- a/crates/worker/src/handlers.rs +++ b/crates/worker/src/handlers.rs @@ -1,6 +1,6 @@ -use std::sync::Arc; use application::services::{FederationEventService, NotificationEventService}; use domain::{errors::DomainError, events::DomainEvent}; +use std::sync::Arc; pub struct NotificationHandler { pub service: Arc, diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index 5ed2abc..8a699fb 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -1,8 +1,8 @@ mod factory; mod handlers; -use futures::StreamExt; use domain::ports::EventConsumer; +use futures::StreamExt; #[tokio::main] async fn main() { @@ -12,8 +12,8 @@ async fn main() { .init(); let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL required"); - let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); - let base_url = std::env::var("BASE_URL").expect("BASE_URL required"); + let nats_url = std::env::var("NATS_URL").unwrap_or_else(|_| "nats://localhost:4222".into()); + let base_url = std::env::var("BASE_URL").expect("BASE_URL required"); tracing::info!("Building worker..."); let (consumer, handlers) = factory::build(&database_url, &base_url, &nats_url).await; @@ -32,8 +32,12 @@ async fn main() { if n.is_ok() && f.is_ok() { (envelope.ack)(); } else { - if let Err(e) = n { tracing::error!("notification handler: {e}"); } - if let Err(e) = f { tracing::error!("federation handler: {e}"); } + if let Err(e) = n { + tracing::error!("notification handler: {e}"); + } + if let Err(e) = f { + tracing::error!("federation handler: {e}"); + } (envelope.nack)(); } }