//! Domain services for K-Notes //! //! Services orchestrate business logic, enforce rules, and coordinate //! between repositories. They are the \"use cases\" of the application. use std::sync::Arc; use uuid::Uuid; use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User}; use crate::errors::{DomainError, DomainResult}; use crate::ports::MessageBroker; use crate::repositories::{NoteRepository, TagRepository, UserRepository}; use crate::value_objects::{Email, NoteTitle, TagName}; /// Request to create a new note #[derive(Debug, Clone)] pub struct CreateNoteRequest { pub user_id: Uuid, /// Title is optional - notes can have no title pub title: Option, pub content: String, /// Tags are pre-validated TagName values pub tags: Vec, pub color: Option, pub is_pinned: bool, } /// Request to update an existing note #[derive(Debug, Clone)] pub struct UpdateNoteRequest { pub id: Uuid, pub user_id: Uuid, // For authorization check /// None means "don't change", Some(None) means "remove title", Some(Some(t)) means "set title" pub title: Option>, pub content: Option, pub is_pinned: Option, pub is_archived: Option, pub color: Option, /// Pre-validated TagName values pub tags: Option>, } /// Service for Note operations pub struct NoteService { note_repo: Arc, tag_repo: Arc, message_broker: Option>, } impl NoteService { pub fn new(note_repo: Arc, tag_repo: Arc) -> Self { Self { note_repo, tag_repo, message_broker: None, } } /// Builder method to set the message broker pub fn with_message_broker(mut self, broker: Arc) -> Self { self.message_broker = Some(broker); self } /// Helper to publish note update events async fn publish_note_event(&self, note: &Note) { if let Some(ref broker) = self.message_broker { if let Err(e) = broker.publish_note_updated(note).await { tracing::error!(note_id = %note.id, "Failed to publish note event: {}", e); } else { tracing::info!(note_id = %note.id, "Published note.updated event"); } } } /// Create a new note with optional tags pub async fn create_note(&self, req: CreateNoteRequest) -> DomainResult { // Title validation is handled by NoteTitle type - no need for runtime check // Tags are pre-validated as TagName values // Validate tag count if req.tags.len() > MAX_TAGS_PER_NOTE { return Err(DomainError::tag_limit_exceeded(req.tags.len())); } // Create the note let mut note = Note::new(req.user_id, req.title, req.content); note.is_pinned = req.is_pinned; if let Some(color) = req.color { note.set_color(color); } // Process tags for tag_name in &req.tags { let tag = self .get_or_create_tag(req.user_id, tag_name.clone()) .await?; note.tags.push(tag); } // Save the note self.note_repo.save(¬e).await?; // Associate tags with the note for tag in ¬e.tags { self.tag_repo.add_to_note(tag.id, note.id).await?; } // Publish event for smart features processing self.publish_note_event(¬e).await; Ok(note) } /// Update an existing note pub async fn update_note(&self, req: UpdateNoteRequest) -> DomainResult { // Find the note let mut note = self .note_repo .find_by_id(req.id) .await? .ok_or(DomainError::NoteNotFound(req.id))?; // Authorization check if note.user_id != req.user_id { return Err(DomainError::unauthorized( "Cannot modify another user's note", )); } // Create version snapshot (save current state) let version = NoteVersion::new( note.id, note.title.as_ref().map(|t| t.as_ref().to_string()), note.content.clone(), ); self.note_repo.save_version(&version).await?; // Apply updates - title is already validated via NoteTitle type if let Some(title) = req.title { note.set_title(title); } if let Some(content) = req.content { note.set_content(content); } if let Some(pinned) = req.is_pinned { note.set_pinned(pinned); } if let Some(archived) = req.is_archived { note.set_archived(archived); } if let Some(color) = req.color { note.set_color(color); } // Handle tag updates if let Some(tag_names) = req.tags { if tag_names.len() > MAX_TAGS_PER_NOTE { return Err(DomainError::tag_limit_exceeded(tag_names.len())); } // Remove old tags for tag in ¬e.tags { self.tag_repo.remove_from_note(tag.id, note.id).await?; } // Add new tags note.tags.clear(); for tag_name in tag_names { let tag = self.get_or_create_tag(note.user_id, tag_name).await?; self.tag_repo.add_to_note(tag.id, note.id).await?; note.tags.push(tag); } } self.note_repo.save(¬e).await?; // Publish event for smart features processing self.publish_note_event(¬e).await; Ok(note) } /// Get a note by ID with authorization check pub async fn get_note(&self, id: Uuid, user_id: Uuid) -> DomainResult { let note = self .note_repo .find_by_id(id) .await? .ok_or(DomainError::NoteNotFound(id))?; if note.user_id != user_id { return Err(DomainError::unauthorized( "Cannot access another user's note", )); } Ok(note) } /// List versions of a note pub async fn list_note_versions( &self, note_id: Uuid, user_id: Uuid, ) -> DomainResult> { // Verify access (re-using get_note for authorization check) self.get_note(note_id, user_id).await?; self.note_repo.find_versions_by_note_id(note_id).await } /// List notes for a user with optional filters pub async fn list_notes(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult> { self.note_repo.find_by_user(user_id, filter).await } /// Delete a note with authorization check pub async fn delete_note(&self, id: Uuid, user_id: Uuid) -> DomainResult<()> { let note = self .note_repo .find_by_id(id) .await? .ok_or(DomainError::NoteNotFound(id))?; if note.user_id != user_id { return Err(DomainError::unauthorized( "Cannot delete another user's note", )); } // Remove tag associations for tag in ¬e.tags { self.tag_repo.remove_from_note(tag.id, id).await?; } self.note_repo.delete(id).await } /// Search notes by query pub async fn search_notes(&self, user_id: Uuid, query: &str) -> DomainResult> { if query.trim().is_empty() { return Ok(Vec::new()); } self.note_repo.search(user_id, query).await } /// Get or create a tag by name /// /// Handles race conditions gracefully: if a concurrent request creates /// the same tag, we catch the unique constraint violation and retry the lookup. async fn get_or_create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult { // First, try to find existing tag if let Some(tag) = self.tag_repo.find_by_name(user_id, name.as_ref()).await? { return Ok(tag); } // Tag doesn't exist, try to create it let tag = Tag::new(name.clone(), user_id); match self.tag_repo.save(&tag).await { Ok(()) => Ok(tag), Err(DomainError::RepositoryError(ref e)) if e.contains("UNIQUE constraint") => { // Race condition: another request created the tag between our check and save // Retry the lookup tracing::debug!(tag_name = %name, "Tag creation race condition detected, retrying lookup"); self.tag_repo .find_by_name(user_id, name.as_ref()) .await? .ok_or_else(|| DomainError::validation("Tag creation race condition")) } Err(e) => Err(e), } } } /// Service for Tag operations pub struct TagService { tag_repo: Arc, } impl TagService { pub fn new(tag_repo: Arc) -> Self { Self { tag_repo } } /// Create a new tag (TagName is pre-validated) pub async fn create_tag(&self, user_id: Uuid, name: TagName) -> DomainResult { // Check if tag already exists if self .tag_repo .find_by_name(user_id, name.as_ref()) .await? .is_some() { return Err(DomainError::TagAlreadyExists(name.into_inner())); } let tag = Tag::new(name, user_id); self.tag_repo.save(&tag).await?; Ok(tag) } /// List all tags for a user pub async fn list_tags(&self, user_id: Uuid) -> DomainResult> { self.tag_repo.find_by_user(user_id).await } /// Delete a tag pub async fn delete_tag(&self, id: Uuid, user_id: Uuid) -> DomainResult<()> { let tag = self .tag_repo .find_by_id(id) .await? .ok_or(DomainError::TagNotFound(id))?; if tag.user_id != user_id { return Err(DomainError::unauthorized( "Cannot delete another user's tag", )); } self.tag_repo.delete(id).await } /// Rename a tag (new_name is pre-validated TagName) pub async fn rename_tag( &self, id: Uuid, user_id: Uuid, new_name: TagName, ) -> DomainResult { // Find the existing tag let mut tag = self .tag_repo .find_by_id(id) .await? .ok_or(DomainError::TagNotFound(id))?; // Authorization check if tag.user_id != user_id { return Err(DomainError::unauthorized( "Cannot rename another user's tag", )); } // Check if new name already exists (and it's not the same tag) if let Some(existing) = self .tag_repo .find_by_name(user_id, new_name.as_ref()) .await? { if existing.id != id { return Err(DomainError::TagAlreadyExists(new_name.into_inner())); } } // Update the name tag.name = new_name; self.tag_repo.save(&tag).await?; Ok(tag) } } /// Service for User operations (OIDC-ready) pub struct UserService { user_repo: Arc, } impl UserService { pub fn new(user_repo: Arc) -> Self { Self { user_repo } } /// Find or create a user by OIDC subject /// This is the main entry point for OIDC authentication pub async fn find_or_create_by_subject( &self, subject: &str, email: Email, ) -> DomainResult { if let Some(user) = self.user_repo.find_by_subject(subject).await? { Ok(user) } else { let user = User::new(subject, email); self.user_repo.save(&user).await?; Ok(user) } } /// Get a user by ID pub async fn get_user(&self, id: Uuid) -> DomainResult { self.user_repo .find_by_id(id) .await? .ok_or(DomainError::UserNotFound(id)) } /// Delete a user and all associated data pub async fn delete_user(&self, id: Uuid) -> DomainResult<()> { // Note: In practice, we'd also need to delete notes and tags // This would be handled by cascade delete in the database // or by coordinating with other services self.user_repo.delete(id).await } } /// Service for Smart Features (Embeddings, Vector Search, Linking) pub struct SmartNoteService { embedding_generator: Arc, vector_store: Arc, link_repo: Arc, } impl SmartNoteService { pub fn new( embedding_generator: Arc, vector_store: Arc, link_repo: Arc, ) -> Self { Self { embedding_generator, vector_store, link_repo, } } /// Process a note to generate embeddings and find similar notes pub async fn process_note(&self, note: &Note) -> DomainResult<()> { // 1. Generate embedding let embedding = self .embedding_generator .generate_embedding(¬e.content) .await?; // 2. Upsert to vector store self.vector_store.upsert(note.id, &embedding).await?; // 3. Find similar notes // TODO: Make limit configurable let similar = self.vector_store.find_similar(&embedding, 5).await?; // 4. Create links let links: Vec = similar .into_iter() .filter(|(id, _)| *id != note.id) // Exclude self .map(|(target_id, score)| crate::entities::NoteLink::new(note.id, target_id, score)) .collect(); // 5. Save links (replacing old ones) if !links.is_empty() { self.link_repo.delete_links_for_source(note.id).await?; self.link_repo.save_links(&links).await?; } Ok(()) } /// Get related notes for a given note ID pub async fn get_related_notes( &self, note_id: Uuid, ) -> DomainResult> { self.link_repo.get_links_for_note(note_id).await } } #[cfg(test)] mod tests { use super::*; use crate::repositories::tests::MockNoteRepository; use std::collections::HashMap; use std::sync::Mutex; // Mock implementations for testing struct MockTagRepository { tags: Mutex>, note_tags: Mutex>, } impl MockTagRepository { fn new() -> Self { Self { tags: Mutex::new(HashMap::new()), note_tags: Mutex::new(HashMap::new()), } } } #[async_trait::async_trait] impl TagRepository for MockTagRepository { async fn find_by_id(&self, id: Uuid) -> DomainResult> { Ok(self.tags.lock().unwrap().get(&id).cloned()) } async fn find_by_user(&self, user_id: Uuid) -> DomainResult> { Ok(self .tags .lock() .unwrap() .values() .filter(|t| t.user_id == user_id) .cloned() .collect()) } async fn find_by_name(&self, user_id: Uuid, name: &str) -> DomainResult> { Ok(self .tags .lock() .unwrap() .values() .find(|t| t.user_id == user_id && t.name.as_ref() == name) .cloned()) } async fn save(&self, tag: &Tag) -> DomainResult<()> { self.tags.lock().unwrap().insert(tag.id, tag.clone()); Ok(()) } async fn delete(&self, id: Uuid) -> DomainResult<()> { self.tags.lock().unwrap().remove(&id); Ok(()) } async fn add_to_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()> { self.note_tags.lock().unwrap().insert((tag_id, note_id), ()); Ok(()) } async fn remove_from_note(&self, tag_id: Uuid, note_id: Uuid) -> DomainResult<()> { self.note_tags.lock().unwrap().remove(&(tag_id, note_id)); Ok(()) } async fn find_by_note(&self, note_id: Uuid) -> DomainResult> { let note_tags = self.note_tags.lock().unwrap(); let tags = self.tags.lock().unwrap(); Ok(note_tags .keys() .filter(|(_, nid)| *nid == note_id) .filter_map(|(tid, _)| tags.get(tid).cloned()) .collect()) } } struct MockUserRepository { users: Mutex>, } impl MockUserRepository { fn new() -> Self { Self { users: Mutex::new(HashMap::new()), } } } #[async_trait::async_trait] impl UserRepository for MockUserRepository { async fn find_by_id(&self, id: Uuid) -> DomainResult> { Ok(self.users.lock().unwrap().get(&id).cloned()) } async fn find_by_subject(&self, subject: &str) -> DomainResult> { Ok(self .users .lock() .unwrap() .values() .find(|u| u.subject == subject) .cloned()) } async fn find_by_email(&self, email: &str) -> DomainResult> { Ok(self .users .lock() .unwrap() .values() .find(|u| u.email_str() == email) .cloned()) } async fn save(&self, user: &User) -> DomainResult<()> { self.users.lock().unwrap().insert(user.id, user.clone()); Ok(()) } async fn delete(&self, id: Uuid) -> DomainResult<()> { self.users.lock().unwrap().remove(&id); Ok(()) } } mod note_service_tests { use super::*; fn create_note_service() -> (NoteService, Uuid) { let note_repo = Arc::new(MockNoteRepository::new()); let tag_repo = Arc::new(MockTagRepository::new()); let user_id = Uuid::new_v4(); (NoteService::new(note_repo, tag_repo), user_id) } #[tokio::test] async fn test_create_note_success() { let (service, user_id) = create_note_service(); let title = NoteTitle::try_from("My Note").ok(); let req = CreateNoteRequest { user_id, title, content: "# Hello World".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(req).await.unwrap(); assert_eq!(note.title_str(), "My Note"); assert_eq!(note.content, "# Hello World"); assert_eq!(note.user_id, user_id); assert_eq!(note.color, "DEFAULT"); assert!(!note.is_pinned); } #[tokio::test] async fn test_create_note_without_title() { let (service, user_id) = create_note_service(); let req = CreateNoteRequest { user_id, title: None, content: "Content without title".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(req).await.unwrap(); assert!(note.title.is_none()); assert_eq!(note.title_str(), ""); assert_eq!(note.content, "Content without title"); } #[tokio::test] async fn test_create_note_with_tags() { let (service, user_id) = create_note_service(); let title = NoteTitle::try_from("Tagged Note").ok(); let tags = vec![ TagName::try_from("work").unwrap(), TagName::try_from("important").unwrap(), ]; let req = CreateNoteRequest { user_id, title, content: "Content".to_string(), tags, color: None, is_pinned: false, }; let note = service.create_note(req).await.unwrap(); assert_eq!(note.tags.len(), 2); assert!(note.tags.iter().any(|t| t.name_str() == "work")); assert!(note.tags.iter().any(|t| t.name_str() == "important")); } #[tokio::test] async fn test_create_note_too_many_tags_fails() { let (service, user_id) = create_note_service(); let tags: Vec = (0..=MAX_TAGS_PER_NOTE) .map(|i| TagName::try_from(format!("tag-{}", i)).unwrap()) .collect(); let title = NoteTitle::try_from("Note").ok(); let req = CreateNoteRequest { user_id, title, content: "Content".to_string(), tags, color: None, is_pinned: false, }; let result = service.create_note(req).await; assert!(matches!(result, Err(DomainError::TagLimitExceeded { .. }))); } #[tokio::test] async fn test_update_note_success() { let (service, user_id) = create_note_service(); // Create a note first let title = NoteTitle::try_from("Original").ok(); let create_req = CreateNoteRequest { user_id, title, content: "Original content".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(create_req).await.unwrap(); // Update it let new_title = NoteTitle::try_from("Updated").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id, title: Some(new_title), content: None, is_pinned: Some(true), is_archived: None, color: Some("red".to_string()), tags: None, }; let updated = service.update_note(update_req).await.unwrap(); assert_eq!(updated.title_str(), "Updated"); assert_eq!(updated.content, "Original content"); // Unchanged assert!(updated.is_pinned); assert_eq!(updated.color, "red"); } #[tokio::test] async fn test_update_note_unauthorized() { let (service, user_id) = create_note_service(); let other_user = Uuid::new_v4(); // Create a note let title = NoteTitle::try_from("My Note").ok(); let create_req = CreateNoteRequest { user_id, title, content: "Content".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(create_req).await.unwrap(); // Try to update with different user let new_title = NoteTitle::try_from("Hacked").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id: other_user, title: Some(new_title), content: None, is_pinned: None, is_archived: None, color: None, tags: None, }; let result = service.update_note(update_req).await; assert!(matches!(result, Err(DomainError::Unauthorized(_)))); } #[tokio::test] async fn test_delete_note_success() { let (service, user_id) = create_note_service(); let title = NoteTitle::try_from("To Delete").ok(); let create_req = CreateNoteRequest { user_id, title, content: "Content".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(create_req).await.unwrap(); service.delete_note(note.id, user_id).await.unwrap(); let result = service.get_note(note.id, user_id).await; assert!(matches!(result, Err(DomainError::NoteNotFound(_)))); } #[tokio::test] async fn test_search_empty_query_returns_empty() { let (service, user_id) = create_note_service(); let results = service.search_notes(user_id, " ").await.unwrap(); assert!(results.is_empty()); } #[tokio::test] async fn test_update_note_creates_version() { let (service, user_id) = create_note_service(); // Create original note let title = NoteTitle::try_from("Original Title").ok(); let create_req = CreateNoteRequest { user_id, title, content: "Original Content".to_string(), tags: vec![], color: None, is_pinned: false, }; let note = service.create_note(create_req).await.unwrap(); // Update the note let new_title = NoteTitle::try_from("New Title").ok(); let update_req = UpdateNoteRequest { id: note.id, user_id, title: Some(new_title), content: Some("New Content".to_string()), is_pinned: None, is_archived: None, color: None, tags: None, }; service.update_note(update_req).await.unwrap(); // Check if version was saved let versions = service .note_repo .find_versions_by_note_id(note.id) .await .unwrap(); assert_eq!(versions.len(), 1); let version = &versions[0]; assert_eq!(version.title, Some("Original Title".to_string())); assert_eq!(version.content, "Original Content"); assert_eq!(version.note_id, note.id); } } mod tag_service_tests { use super::*; fn create_tag_service() -> (TagService, Uuid) { let tag_repo = Arc::new(MockTagRepository::new()); let user_id = Uuid::new_v4(); (TagService::new(tag_repo), user_id) } #[tokio::test] async fn test_create_tag_success() { let (service, user_id) = create_tag_service(); let name = TagName::try_from("Work").unwrap(); let tag = service.create_tag(user_id, name).await.unwrap(); assert_eq!(tag.name_str(), "work"); // Lowercase assert_eq!(tag.user_id, user_id); } #[tokio::test] async fn test_create_duplicate_tag_fails() { let (service, user_id) = create_tag_service(); let name1 = TagName::try_from("work").unwrap(); service.create_tag(user_id, name1).await.unwrap(); let name2 = TagName::try_from("WORK").unwrap(); // Case-insensitive let result = service.create_tag(user_id, name2).await; assert!(matches!(result, Err(DomainError::TagAlreadyExists(_)))); } } mod user_service_tests { use super::*; fn create_user_service() -> UserService { let user_repo = Arc::new(MockUserRepository::new()); UserService::new(user_repo) } #[tokio::test] async fn test_find_or_create_creates_new_user() { let service = create_user_service(); let email = Email::try_from("test@example.com").unwrap(); let user = service .find_or_create_by_subject("oidc|123", email) .await .unwrap(); assert_eq!(user.subject, "oidc|123"); assert_eq!(user.email_str(), "test@example.com"); } #[tokio::test] async fn test_find_or_create_returns_existing_user() { let service = create_user_service(); let email1 = Email::try_from("test@example.com").unwrap(); let user1 = service .find_or_create_by_subject("oidc|123", email1) .await .unwrap(); let email2 = Email::try_from("test@example.com").unwrap(); let user2 = service .find_or_create_by_subject("oidc|123", email2) .await .unwrap(); assert_eq!(user1.id, user2.id); } } }