diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index d07542b..05db0a8 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -1 +1,2 @@ +pub mod services; pub mod use_cases; diff --git a/crates/application/src/services/mod.rs b/crates/application/src/services/mod.rs new file mode 100644 index 0000000..480fdc6 --- /dev/null +++ b/crates/application/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod notification_event; +pub use notification_event::NotificationEventService; diff --git a/crates/application/src/services/notification_event.rs b/crates/application/src/services/notification_event.rs new file mode 100644 index 0000000..6747f06 --- /dev/null +++ b/crates/application/src/services/notification_event.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; +use chrono::Utc; +use domain::{ + errors::DomainError, + events::DomainEvent, + models::notification::{Notification, NotificationType}, + ports::{NotificationRepository, ThoughtRepository}, + value_objects::NotificationId, +}; + +pub struct NotificationEventService { + pub thoughts: Arc, + pub notifications: Arc, +} + +impl NotificationEventService { + pub async fn process(&self, event: &DomainEvent) -> Result<(), DomainError> { + match event { + 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 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 } => { + let thought = match self.thoughts.find_by_id(thought_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if 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::ThoughtCreated { thought_id, user_id, in_reply_to_id } => { + let reply_to_id = match in_reply_to_id { + Some(id) => id, + None => return Ok(()), + }; + let original = match self.thoughts.find_by_id(reply_to_id).await? { + Some(t) => t, + None => return Ok(()), + }; + if 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(()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::{ + models::{thought::{Thought, Visibility}, user::User}, + testing::TestStore, + value_objects::*, + }; + use std::sync::Arc; + + fn alice() -> User { + User::new_local( + UserId::new(), + Username::new("alice").unwrap(), + Email::new("alice@ex.com").unwrap(), + PasswordHash("h".into()), + ) + } + + #[tokio::test] + async fn like_creates_notification_for_thought_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let thought = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("hello").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: bob_id, + thought_id: thought.id.clone(), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Like)); + } + + #[tokio::test] + async fn self_like_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let thought = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("hello").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(thought.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::LikeAdded { + like_id: LikeId::new(), + user_id: alice.id.clone(), + thought_id: thought.id.clone(), + }).await.unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn follow_accepted_creates_notification() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::FollowAccepted { + follower_id: bob_id, + following_id: alice.id.clone(), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Follow)); + } + + #[tokio::test] + async fn reply_creates_notification_for_original_author() { + let store = TestStore::default(); + let alice = alice(); + let bob_id = UserId::new(); + let original = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("original").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: bob_id, + in_reply_to_id: Some(original.id.clone()), + }).await.unwrap(); + let notifs = store.notifications.lock().unwrap(); + assert_eq!(notifs.len(), 1); + assert!(matches!(notifs[0].notification_type, NotificationType::Reply)); + } + + #[tokio::test] + async fn self_reply_creates_no_notification() { + let store = TestStore::default(); + let alice = alice(); + let original = Thought::new_local( + ThoughtId::new(), alice.id.clone(), + Content::new_local("original").unwrap(), + None, Visibility::Public, None, false, + ); + store.thoughts.lock().unwrap().push(original.clone()); + let svc = NotificationEventService { + thoughts: Arc::new(store.clone()), + notifications: Arc::new(store.clone()), + }; + svc.process(&DomainEvent::ThoughtCreated { + thought_id: ThoughtId::new(), + user_id: alice.id.clone(), + in_reply_to_id: Some(original.id.clone()), + }).await.unwrap(); + assert!(store.notifications.lock().unwrap().is_empty()); + } +}