feat: Introduce note version history with dedicated UI, API, and database schema.

This commit is contained in:
2025-12-23 03:08:14 +01:00
parent 7aad3b7d84
commit c441f14bfa
12 changed files with 408 additions and 10 deletions

View File

@@ -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 {

View File

@@ -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};

View File

@@ -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(&note_id).cloned().unwrap_or_default())
}
}
#[tokio::test]

View File

@@ -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 {