feat: Introduce note version history with dedicated UI, API, and database schema.
This commit is contained in:
@@ -182,6 +182,28 @@ impl Note {
|
||||
}
|
||||
}
|
||||
|
||||
/// A snapshot of a note's state at a specific point in time.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NoteVersion {
|
||||
pub id: Uuid,
|
||||
pub note_id: Uuid,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl NoteVersion {
|
||||
pub fn new(note_id: Uuid, title: String, content: String) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
note_id,
|
||||
title,
|
||||
content,
|
||||
created_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter options for querying notes
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct NoteFilter {
|
||||
|
||||
@@ -14,7 +14,7 @@ pub mod repositories;
|
||||
pub mod services;
|
||||
|
||||
// Re-export commonly used types at crate root
|
||||
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, Tag, User};
|
||||
pub use entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User};
|
||||
pub use errors::{DomainError, DomainResult};
|
||||
pub use repositories::{NoteRepository, TagRepository, UserRepository};
|
||||
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService};
|
||||
|
||||
@@ -27,6 +27,15 @@ pub trait NoteRepository: Send + Sync {
|
||||
|
||||
/// Full-text search across note titles and content
|
||||
async fn search(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>>;
|
||||
|
||||
/// Save a note version
|
||||
async fn save_version(&self, version: &crate::entities::NoteVersion) -> DomainResult<()>;
|
||||
|
||||
/// Find all versions for a note
|
||||
async fn find_versions_by_note_id(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
) -> DomainResult<Vec<crate::entities::NoteVersion>>;
|
||||
}
|
||||
|
||||
/// Repository port for User persistence
|
||||
@@ -85,12 +94,14 @@ pub(crate) mod tests {
|
||||
/// In-memory mock implementation for testing
|
||||
pub struct MockNoteRepository {
|
||||
notes: Mutex<HashMap<Uuid, Note>>,
|
||||
versions: Mutex<HashMap<Uuid, Vec<crate::entities::NoteVersion>>>,
|
||||
}
|
||||
|
||||
impl MockNoteRepository {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
notes: Mutex::new(HashMap::new()),
|
||||
versions: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +150,21 @@ pub(crate) mod tests {
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn save_version(&self, version: &crate::entities::NoteVersion) -> DomainResult<()> {
|
||||
let mut versions = self.versions.lock().unwrap();
|
||||
let note_versions = versions.entry(version.note_id).or_insert_with(Vec::new);
|
||||
note_versions.push(version.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_versions_by_note_id(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
) -> DomainResult<Vec<crate::entities::NoteVersion>> {
|
||||
let versions = self.versions.lock().unwrap();
|
||||
Ok(versions.get(¬e_id).cloned().unwrap_or_default())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, Tag, User};
|
||||
use crate::entities::{MAX_TAGS_PER_NOTE, Note, NoteFilter, NoteVersion, Tag, User};
|
||||
use crate::errors::{DomainError, DomainResult};
|
||||
use crate::repositories::{NoteRepository, TagRepository, UserRepository};
|
||||
|
||||
@@ -100,6 +100,10 @@ impl NoteService {
|
||||
));
|
||||
}
|
||||
|
||||
// Create version snapshot (save current state)
|
||||
let version = NoteVersion::new(note.id, note.title.clone(), note.content.clone());
|
||||
self.note_repo.save_version(&version).await?;
|
||||
|
||||
// Apply updates
|
||||
if let Some(title) = req.title {
|
||||
if title.trim().is_empty() {
|
||||
@@ -165,6 +169,18 @@ impl NoteService {
|
||||
Ok(note)
|
||||
}
|
||||
|
||||
/// List versions of a note
|
||||
pub async fn list_note_versions(
|
||||
&self,
|
||||
note_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> DomainResult<Vec<crate::entities::NoteVersion>> {
|
||||
// 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<Vec<Note>> {
|
||||
self.note_repo.find_by_user(user_id, filter).await
|
||||
@@ -618,6 +634,47 @@ mod tests {
|
||||
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 create_req = CreateNoteRequest {
|
||||
user_id,
|
||||
title: "Original Title".to_string(),
|
||||
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 update_req = UpdateNoteRequest {
|
||||
id: note.id,
|
||||
user_id,
|
||||
title: Some("New Title".to_string()),
|
||||
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, "Original Title");
|
||||
assert_eq!(version.content, "Original Content");
|
||||
assert_eq!(version.note_id, note.id);
|
||||
}
|
||||
}
|
||||
|
||||
mod tag_service_tests {
|
||||
|
||||
Reference in New Issue
Block a user