diff --git a/k-notes-frontend/src/components/note-card.tsx b/k-notes-frontend/src/components/note-card.tsx
index c55375f..ed58714 100644
--- a/k-notes-frontend/src/components/note-card.tsx
+++ b/k-notes-frontend/src/components/note-card.tsx
@@ -2,7 +2,7 @@ import { type Note, useDeleteNote, useUpdateNote } from "@/hooks/use-notes";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Pin, Archive, Trash2, Edit } from "lucide-react";
+import { Pin, Archive, Trash2, Edit, History } from "lucide-react";
import { format } from "date-fns";
import { toast } from "sonner";
import { useState } from "react";
@@ -11,6 +11,7 @@ import { NoteForm } from "./note-form";
import ReactMarkdown from "react-markdown";
import { getNoteColor } from "@/lib/constants";
import clsx from "clsx";
+import { VersionHistoryDialog } from "./version-history-dialog";
interface NoteCardProps {
note: Note;
@@ -20,6 +21,7 @@ export function NoteCard({ note }: NoteCardProps) {
const { mutate: deleteNote } = useDeleteNote();
const { mutate: updateNote } = useUpdateNote();
const [editing, setEditing] = useState(false);
+ const [historyOpen, setHistoryOpen] = useState(false);
// Archive toggle
const toggleArchive = () => {
@@ -92,6 +94,9 @@ export function NoteCard({ note }: NoteCardProps) {
))}
+
@@ -126,6 +131,13 @@ export function NoteCard({ note }: NoteCardProps) {
/>
+
+
>
);
}
diff --git a/k-notes-frontend/src/components/version-history-dialog.tsx b/k-notes-frontend/src/components/version-history-dialog.tsx
new file mode 100644
index 0000000..562930d
--- /dev/null
+++ b/k-notes-frontend/src/components/version-history-dialog.tsx
@@ -0,0 +1,131 @@
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { useNoteVersions, type NoteVersion, useUpdateNote } from "@/hooks/use-notes";
+import { formatDistanceToNow, format } from "date-fns";
+import { Loader2, History, Download, RotateCcw } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
+
+interface VersionHistoryDialogProps {
+ noteId: string;
+ noteTitle: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export function VersionHistoryDialog({
+ noteId,
+ noteTitle,
+ open,
+ onOpenChange,
+}: VersionHistoryDialogProps) {
+ const { data: versions, isLoading } = useNoteVersions(noteId, open);
+ const { mutate: updateNote } = useUpdateNote();
+
+ const handleDownload = (version: NoteVersion) => {
+ const text = `${version.title}\n\n${version.content}`;
+ const blob = new Blob([text], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `${version.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}-${format(new Date(version.created_at), "yyyy-MM-dd-HH-mm")}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ toast.success("Downloaded version");
+ };
+
+ const handleRestore = (version: NoteVersion) => {
+ if (confirm("Are you sure you want to restore this version? The current version will be saved as a new history entry.")) {
+ updateNote({
+ id: noteId,
+ title: version.title,
+ content: version.content,
+ }, {
+ onSuccess: () => {
+ toast.success("Version restored");
+ onOpenChange(false);
+ }
+ });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/k-notes-frontend/src/hooks/use-notes.ts b/k-notes-frontend/src/hooks/use-notes.ts
index 58f7174..b5568d5 100644
--- a/k-notes-frontend/src/hooks/use-notes.ts
+++ b/k-notes-frontend/src/hooks/use-notes.ts
@@ -90,6 +90,22 @@ export function useDeleteNote() {
});
}
+export interface NoteVersion {
+ id: string;
+ note_id: string;
+ title: string;
+ content: string;
+ created_at: string;
+}
+
+export function useNoteVersions(noteId: string, enabled: boolean = false) {
+ return useQuery({
+ queryKey: ["notes", noteId, "versions"],
+ queryFn: () => api.get(`/notes/${noteId}/versions`),
+ enabled: enabled && !!noteId,
+ });
+}
+
export function useTags() {
return useQuery({
queryKey: ["tags"],
diff --git a/migrations/20251223030000_add_note_versions.sql b/migrations/20251223030000_add_note_versions.sql
new file mode 100644
index 0000000..c987043
--- /dev/null
+++ b/migrations/20251223030000_add_note_versions.sql
@@ -0,0 +1,11 @@
+-- Add note_versions table
+CREATE TABLE note_versions (
+ id TEXT PRIMARY KEY,
+ note_id TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
+);
+
+CREATE INDEX idx_note_versions_note_id ON note_versions(note_id);
diff --git a/notes-api/src/dto.rs b/notes-api/src/dto.rs
index f25a669..e617a5b 100644
--- a/notes-api/src/dto.rs
+++ b/notes-api/src/dto.rs
@@ -146,3 +146,25 @@ pub struct UserResponse {
pub email: String,
pub created_at: DateTime
,
}
+
+/// Note Version response DTO
+#[derive(Debug, Serialize)]
+pub struct NoteVersionResponse {
+ pub id: Uuid,
+ pub note_id: Uuid,
+ pub title: String,
+ pub content: String,
+ pub created_at: DateTime,
+}
+
+impl From for NoteVersionResponse {
+ fn from(version: notes_domain::NoteVersion) -> Self {
+ Self {
+ id: version.id,
+ note_id: version.note_id,
+ title: version.title,
+ content: version.content,
+ created_at: version.created_at,
+ }
+ }
+}
diff --git a/notes-api/src/routes/mod.rs b/notes-api/src/routes/mod.rs
index 25ee54d..7da7636 100644
--- a/notes-api/src/routes/mod.rs
+++ b/notes-api/src/routes/mod.rs
@@ -28,6 +28,7 @@ pub fn api_v1_router() -> Router {
.patch(notes::update_note)
.delete(notes::delete_note),
)
+ .route("/notes/{id}/versions", get(notes::list_note_versions))
// Search route
.route("/search", get(notes::search_notes))
// Import/Export routes
diff --git a/notes-api/src/routes/notes.rs b/notes-api/src/routes/notes.rs
index 0e2a59e..c427679 100644
--- a/notes-api/src/routes/notes.rs
+++ b/notes-api/src/routes/notes.rs
@@ -177,3 +177,28 @@ pub async fn search_notes(
Ok(Json(response))
}
+
+/// List versions of a note
+/// GET /api/v1/notes/:id/versions
+pub async fn list_note_versions(
+ State(state): State,
+ auth: AuthSession,
+ Path(id): Path,
+) -> ApiResult>> {
+ let user = auth
+ .user
+ .ok_or(ApiError::Domain(notes_domain::DomainError::Unauthorized(
+ "Login required".to_string(),
+ )))?;
+ let user_id = user.id();
+
+ let service = NoteService::new(state.note_repo, state.tag_repo);
+
+ let versions = service.list_note_versions(id, user_id).await?;
+ let response: Vec = versions
+ .into_iter()
+ .map(crate::dto::NoteVersionResponse::from)
+ .collect();
+
+ Ok(Json(response))
+}
diff --git a/notes-domain/src/entities.rs b/notes-domain/src/entities.rs
index cbcbdcc..1dd5349 100644
--- a/notes-domain/src/entities.rs
+++ b/notes-domain/src/entities.rs
@@ -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,
+}
+
+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 {
diff --git a/notes-domain/src/lib.rs b/notes-domain/src/lib.rs
index 29a69b0..ef90037 100644
--- a/notes-domain/src/lib.rs
+++ b/notes-domain/src/lib.rs
@@ -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};
diff --git a/notes-domain/src/repositories.rs b/notes-domain/src/repositories.rs
index fe29a66..2c50652 100644
--- a/notes-domain/src/repositories.rs
+++ b/notes-domain/src/repositories.rs
@@ -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>;
+
+ /// 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>;
}
/// Repository port for User persistence
@@ -85,12 +94,14 @@ pub(crate) mod tests {
/// In-memory mock implementation for testing
pub struct MockNoteRepository {
notes: Mutex>,
+ versions: Mutex>>,
}
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> {
+ let versions = self.versions.lock().unwrap();
+ Ok(versions.get(¬e_id).cloned().unwrap_or_default())
+ }
}
#[tokio::test]
diff --git a/notes-domain/src/services.rs b/notes-domain/src/services.rs
index 4f04319..40daab3 100644
--- a/notes-domain/src/services.rs
+++ b/notes-domain/src/services.rs
@@ -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> {
+ // 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
@@ -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 {
diff --git a/notes-infra/src/note_repository.rs b/notes-infra/src/note_repository.rs
index 34c2b3b..8383b64 100644
--- a/notes-infra/src/note_repository.rs
+++ b/notes-infra/src/note_repository.rs
@@ -6,7 +6,7 @@ use sqlx::{FromRow, SqlitePool};
use uuid::Uuid;
use notes_domain::{
- DomainError, DomainResult, Note, NoteFilter, NoteRepository, Tag, TagRepository,
+ DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag, TagRepository,
};
use crate::tag_repository::SqliteTagRepository;
@@ -72,6 +72,40 @@ impl NoteRow {
}
}
+#[derive(Debug, FromRow)]
+struct NoteVersionRow {
+ id: String,
+ note_id: String,
+ title: String,
+ content: String,
+ created_at: String,
+}
+
+impl NoteVersionRow {
+ fn try_into_version(self) -> Result {
+ let id = Uuid::parse_str(&self.id)
+ .map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
+ let note_id = Uuid::parse_str(&self.note_id)
+ .map_err(|e| DomainError::RepositoryError(format!("Invalid UUID: {}", e)))?;
+
+ let created_at = DateTime::parse_from_rfc3339(&self.created_at)
+ .map(|dt| dt.with_timezone(&Utc))
+ .or_else(|_| {
+ chrono::NaiveDateTime::parse_from_str(&self.created_at, "%Y-%m-%d %H:%M:%S")
+ .map(|dt| dt.and_utc())
+ })
+ .map_err(|e| DomainError::RepositoryError(format!("Invalid datetime: {}", e)))?;
+
+ Ok(NoteVersion {
+ id,
+ note_id,
+ title: self.title,
+ content: self.content,
+ created_at,
+ })
+ }
+}
+
#[async_trait]
impl NoteRepository for SqliteNoteRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult