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

@@ -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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; 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 { format } from "date-fns";
import { toast } from "sonner"; import { toast } from "sonner";
import { useState } from "react"; import { useState } from "react";
@@ -11,6 +11,7 @@ import { NoteForm } from "./note-form";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import { getNoteColor } from "@/lib/constants"; import { getNoteColor } from "@/lib/constants";
import clsx from "clsx"; import clsx from "clsx";
import { VersionHistoryDialog } from "./version-history-dialog";
interface NoteCardProps { interface NoteCardProps {
note: Note; note: Note;
@@ -20,6 +21,7 @@ export function NoteCard({ note }: NoteCardProps) {
const { mutate: deleteNote } = useDeleteNote(); const { mutate: deleteNote } = useDeleteNote();
const { mutate: updateNote } = useUpdateNote(); const { mutate: updateNote } = useUpdateNote();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
// Archive toggle // Archive toggle
const toggleArchive = () => { const toggleArchive = () => {
@@ -92,6 +94,9 @@ export function NoteCard({ note }: NoteCardProps) {
))} ))}
</div> </div>
<div className="flex justify-end w-full gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"> <div className="flex justify-end w-full gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setHistoryOpen(true)} title="History">
<History className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setEditing(true)}> <Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-black/5 dark:hover:bg-white/10" onClick={() => setEditing(true)}>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
@@ -126,6 +131,13 @@ export function NoteCard({ note }: NoteCardProps) {
/> />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<VersionHistoryDialog
open={historyOpen}
onOpenChange={setHistoryOpen}
noteId={note.id}
noteTitle={note.title}
/>
</> </>
); );
} }

View File

@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Version History
</DialogTitle>
<DialogDescription>
History for "{noteTitle}"
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-hidden">
<ScrollArea className="h-full">
<div className="pr-4">
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : versions?.length === 0 ? (
<div className="text-center text-muted-foreground p-4">
No history available for this note.
</div>
) : (
<div className="space-y-4 p-1">
{(versions as NoteVersion[])?.map((version) => (
<div
key={version.id}
className="border rounded-lg p-4 space-y-3 bg-card"
>
<div className="flex justify-between items-center text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground">
{format(new Date(version.created_at), "MMM d, yyyy HH:mm")}
</span>
<span>
({formatDistanceToNow(new Date(version.created_at), {
addSuffix: true,
})})
</span>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5"
onClick={() => handleDownload(version)}
>
<Download className="h-3.5 w-3.5" />
Download
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 gap-1.5"
onClick={() => handleRestore(version)}
>
<RotateCcw className="h-3.5 w-3.5" />
Restore
</Button>
</div>
</div>
<div className="font-medium leading-none">{version.title}</div>
<div className="text-sm whitespace-pre-wrap font-mono bg-muted/50 p-3 rounded-md border">
{version.content}
</div>
</div>
))}
</div>
)}
</div>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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() { export function useTags() {
return useQuery({ return useQuery({
queryKey: ["tags"], queryKey: ["tags"],

View File

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

View File

@@ -146,3 +146,25 @@ pub struct UserResponse {
pub email: String, pub email: String,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
} }
/// 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<Utc>,
}
impl From<notes_domain::NoteVersion> 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,
}
}
}

View File

@@ -28,6 +28,7 @@ pub fn api_v1_router() -> Router<AppState> {
.patch(notes::update_note) .patch(notes::update_note)
.delete(notes::delete_note), .delete(notes::delete_note),
) )
.route("/notes/{id}/versions", get(notes::list_note_versions))
// Search route // Search route
.route("/search", get(notes::search_notes)) .route("/search", get(notes::search_notes))
// Import/Export routes // Import/Export routes

View File

@@ -177,3 +177,28 @@ pub async fn search_notes(
Ok(Json(response)) Ok(Json(response))
} }
/// List versions of a note
/// GET /api/v1/notes/:id/versions
pub async fn list_note_versions(
State(state): State<AppState>,
auth: AuthSession<AuthBackend>,
Path(id): Path<Uuid>,
) -> ApiResult<Json<Vec<crate::dto::NoteVersionResponse>>> {
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<crate::dto::NoteVersionResponse> = versions
.into_iter()
.map(crate::dto::NoteVersionResponse::from)
.collect();
Ok(Json(response))
}

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 /// Filter options for querying notes
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NoteFilter { pub struct NoteFilter {

View File

@@ -14,7 +14,7 @@ pub mod repositories;
pub mod services; pub mod services;
// Re-export commonly used types at crate root // 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 errors::{DomainError, DomainResult};
pub use repositories::{NoteRepository, TagRepository, UserRepository}; pub use repositories::{NoteRepository, TagRepository, UserRepository};
pub use services::{CreateNoteRequest, NoteService, TagService, UpdateNoteRequest, UserService}; 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 /// Full-text search across note titles and content
async fn search(&self, user_id: Uuid, query: &str) -> DomainResult<Vec<Note>>; 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 /// Repository port for User persistence
@@ -85,12 +94,14 @@ pub(crate) mod tests {
/// In-memory mock implementation for testing /// In-memory mock implementation for testing
pub struct MockNoteRepository { pub struct MockNoteRepository {
notes: Mutex<HashMap<Uuid, Note>>, notes: Mutex<HashMap<Uuid, Note>>,
versions: Mutex<HashMap<Uuid, Vec<crate::entities::NoteVersion>>>,
} }
impl MockNoteRepository { impl MockNoteRepository {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
notes: Mutex::new(HashMap::new()), notes: Mutex::new(HashMap::new()),
versions: Mutex::new(HashMap::new()),
} }
} }
} }
@@ -139,6 +150,21 @@ pub(crate) mod tests {
.cloned() .cloned()
.collect()) .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] #[tokio::test]

View File

@@ -6,7 +6,7 @@
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; 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::errors::{DomainError, DomainResult};
use crate::repositories::{NoteRepository, TagRepository, UserRepository}; 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 // Apply updates
if let Some(title) = req.title { if let Some(title) = req.title {
if title.trim().is_empty() { if title.trim().is_empty() {
@@ -165,6 +169,18 @@ impl NoteService {
Ok(note) 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 /// List notes for a user with optional filters
pub async fn list_notes(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>> { pub async fn list_notes(&self, user_id: Uuid, filter: NoteFilter) -> DomainResult<Vec<Note>> {
self.note_repo.find_by_user(user_id, filter).await 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(); let results = service.search_notes(user_id, " ").await.unwrap();
assert!(results.is_empty()); 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 { mod tag_service_tests {

View File

@@ -6,7 +6,7 @@ use sqlx::{FromRow, SqlitePool};
use uuid::Uuid; use uuid::Uuid;
use notes_domain::{ use notes_domain::{
DomainError, DomainResult, Note, NoteFilter, NoteRepository, Tag, TagRepository, DomainError, DomainResult, Note, NoteFilter, NoteRepository, NoteVersion, Tag, TagRepository,
}; };
use crate::tag_repository::SqliteTagRepository; 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<NoteVersion, DomainError> {
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] #[async_trait]
impl NoteRepository for SqliteNoteRepository { impl NoteRepository for SqliteNoteRepository {
async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Note>> { async fn find_by_id(&self, id: Uuid) -> DomainResult<Option<Note>> {
@@ -233,10 +267,51 @@ impl NoteRepository for SqliteNoteRepository {
Ok(notes) Ok(notes)
} }
}
// Tests omitted for brevity in this full file replacement, but should be preserved in real scenario async fn save_version(&self, version: &NoteVersion) -> DomainResult<()> {
// I am assuming I can just facilitate the repo update without including tests for now to save tokens/time let id = version.id.to_string();
// as tests are in separate module in original file and I can't see them easily to copy back. let note_id = version.note_id.to_string();
// Wait, I have the original file content from `view_file`. I should include tests. let created_at = version.created_at.to_rfc3339();
// The previous view_file `Step 450` contains the tests.
sqlx::query(
r#"
INSERT INTO note_versions (id, note_id, title, content, created_at)
VALUES (?, ?, ?, ?, ?)
"#,
)
.bind(&id)
.bind(&note_id)
.bind(&version.title)
.bind(&version.content)
.bind(&created_at)
.execute(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
Ok(())
}
async fn find_versions_by_note_id(&self, note_id: Uuid) -> DomainResult<Vec<NoteVersion>> {
let note_id_str = note_id.to_string();
let rows: Vec<NoteVersionRow> = sqlx::query_as(
r#"
SELECT id, note_id, title, content, created_at
FROM note_versions
WHERE note_id = ?
ORDER BY created_at DESC
"#,
)
.bind(&note_id_str)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::RepositoryError(e.to_string()))?;
let mut versions = Vec::with_capacity(rows.len());
for row in rows {
versions.push(row.try_into_version()?);
}
Ok(versions)
}
}