From 9ed7d8aceca975849bbcf592c6ab0eb286cd87ff Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 22 Mar 2026 17:42:47 -0300 Subject: [PATCH] feat: MVP-4 Sprint 3 - Version history - Add NoteVersion model for storing note snapshots - Add versions API (list, create, get, restore) - Add version history UI dialog in note detail page - Create version snapshot before each note update --- prisma/schema.prisma | 10 ++ src/app/api/notes/[id]/route.ts | 3 + .../notes/[id]/versions/[versionId]/route.ts | 23 +++ src/app/api/notes/[id]/versions/route.ts | 23 +++ src/app/notes/[id]/page.tsx | 2 + src/components/version-history.tsx | 158 ++++++++++++++++++ src/lib/versions.ts | 96 +++++++++++ 7 files changed, 315 insertions(+) create mode 100644 src/app/api/notes/[id]/versions/[versionId]/route.ts create mode 100644 src/app/api/notes/[id]/versions/route.ts create mode 100644 src/components/version-history.tsx create mode 100644 src/lib/versions.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d87c3b4..325ddea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -78,3 +78,13 @@ model NoteCoUsage { @@index([fromNoteId]) @@index([toNoteId]) } + +model NoteVersion { + id String @id @default(cuid()) + noteId String + title String + content String + createdAt DateTime @default(now()) + + @@index([noteId, createdAt]) +} diff --git a/src/app/api/notes/[id]/route.ts b/src/app/api/notes/[id]/route.ts index 065db45..3f49288 100644 --- a/src/app/api/notes/[id]/route.ts +++ b/src/app/api/notes/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest } from 'next/server' import { prisma } from '@/lib/prisma' import { updateNoteSchema } from '@/lib/validators' import { syncBacklinks } from '@/lib/backlinks' +import { createVersion } from '@/lib/versions' import { createErrorResponse, createSuccessResponse, NotFoundError, ValidationError } from '@/lib/errors' export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { @@ -39,6 +40,8 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: throw new NotFoundError('Note') } + await createVersion(id) + await prisma.noteTag.deleteMany({ where: { noteId: id } }) const note = await prisma.note.update({ diff --git a/src/app/api/notes/[id]/versions/[versionId]/route.ts b/src/app/api/notes/[id]/versions/[versionId]/route.ts new file mode 100644 index 0000000..6e5af93 --- /dev/null +++ b/src/app/api/notes/[id]/versions/[versionId]/route.ts @@ -0,0 +1,23 @@ +import { NextRequest } from 'next/server' +import { getVersion, restoreVersion } from '@/lib/versions' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) { + try { + const { versionId } = await params + const version = await getVersion(versionId) + return createSuccessResponse(version) + } catch (error) { + return createErrorResponse(error) + } +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string; versionId: string }> }) { + try { + const { id, versionId } = await params + const note = await restoreVersion(id, versionId) + return createSuccessResponse(note) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/api/notes/[id]/versions/route.ts b/src/app/api/notes/[id]/versions/route.ts new file mode 100644 index 0000000..2285ca7 --- /dev/null +++ b/src/app/api/notes/[id]/versions/route.ts @@ -0,0 +1,23 @@ +import { NextRequest } from 'next/server' +import { createVersion, getVersions } from '@/lib/versions' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' + +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const versions = await getVersions(id) + return createSuccessResponse(versions) + } catch (error) { + return createErrorResponse(error) + } +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params + const version = await createVersion(id) + return createSuccessResponse(version, 201) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index b26306c..60b4628 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -7,6 +7,7 @@ import { NoteConnections } from '@/components/note-connections' import { MarkdownContent } from '@/components/markdown-content' import { DeleteNoteButton } from '@/components/delete-note-button' import { TrackNoteView } from '@/components/track-note-view' +import { VersionHistory } from '@/components/version-history' import Link from 'next/link' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -73,6 +74,7 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id: Editar + diff --git a/src/components/version-history.tsx b/src/components/version-history.tsx new file mode 100644 index 0000000..83962a8 --- /dev/null +++ b/src/components/version-history.tsx @@ -0,0 +1,158 @@ +'use client' +import { useState } from 'react' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { History, RotateCcw } from 'lucide-react' +import { toast } from 'sonner' + +interface Version { + id: string + title: string + content: string + createdAt: string +} + +interface VersionHistoryProps { + noteId: string +} + +export function VersionHistory({ noteId }: VersionHistoryProps) { + const [open, setOpen] = useState(false) + const [versions, setVersions] = useState([]) + const [loading, setLoading] = useState(false) + const [restoring, setRestoring] = useState(null) + const [confirmRestore, setConfirmRestore] = useState(null) + + const fetchVersions = async () => { + setLoading(true) + try { + const res = await fetch(`/api/notes/${noteId}/versions`) + const data = await res.json() + if (data.success) { + setVersions(data.data) + } + } catch { + toast.error('Error al cargar las versiones') + } finally { + setLoading(false) + } + } + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + if (newOpen && versions.length === 0) { + fetchVersions() + } + } + + const handleRestore = async (versionId: string) => { + setRestoring(versionId) + try { + const res = await fetch(`/api/notes/${noteId}/versions/${versionId}`, { + method: 'PUT', + }) + const data = await res.json() + if (data.success) { + toast.success('Versión restaurada correctamente') + setOpen(false) + window.location.reload() + } else { + toast.error(data.error || 'Error al restaurar la versión') + } + } catch { + toast.error('Error al restaurar la versión') + } finally { + setRestoring(null) + setConfirmRestore(null) + } + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString('es-ES', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } + + return ( + + + + + + + Historial de versiones + + Revisa y restaura versiones anteriores de esta nota + + + +
+ {loading ? ( +
Cargando versiones...
+ ) : versions.length === 0 ? ( +
No hay versiones guardadas
+ ) : ( +
+ {versions.map((version) => ( +
+
+
+ {formatDate(version.createdAt)} +
+
+ {version.title} +
+
+ {version.content.substring(0, 50)}... +
+
+ + {confirmRestore === version.id ? ( +
+ ¿Restaurar? + + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+
+
+ ) +} diff --git a/src/lib/versions.ts b/src/lib/versions.ts new file mode 100644 index 0000000..1f4bef6 --- /dev/null +++ b/src/lib/versions.ts @@ -0,0 +1,96 @@ +import { prisma } from '@/lib/prisma' +import { NotFoundError } from '@/lib/errors' + +export async function createVersion(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> { + const note = await prisma.note.findUnique({ + where: { id: noteId }, + select: { id: true, title: true, content: true }, + }) + + if (!note) { + throw new NotFoundError('Note') + } + + const version = await prisma.noteVersion.create({ + data: { + noteId: note.id, + title: note.title, + content: note.content, + }, + }) + + return version +} + +export async function getVersions(noteId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }[]> { + const note = await prisma.note.findUnique({ + where: { id: noteId }, + }) + + if (!note) { + throw new NotFoundError('Note') + } + + return prisma.noteVersion.findMany({ + where: { noteId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + noteId: true, + title: true, + content: true, + createdAt: true, + }, + }) +} + +export async function getVersion(versionId: string): Promise<{ id: string; noteId: string; title: string; content: string; createdAt: Date }> { + const version = await prisma.noteVersion.findUnique({ + where: { id: versionId }, + }) + + if (!version) { + throw new NotFoundError('Version') + } + + return version +} + +export async function restoreVersion(noteId: string, versionId: string): Promise<{ id: string; title: string; content: string; updatedAt: Date }> { + const note = await prisma.note.findUnique({ + where: { id: noteId }, + }) + + if (!note) { + throw new NotFoundError('Note') + } + + const version = await prisma.noteVersion.findUnique({ + where: { id: versionId }, + }) + + if (!version) { + throw new NotFoundError('Version') + } + + if (version.noteId !== noteId) { + throw new NotFoundError('Version') + } + + const updatedNote = await prisma.note.update({ + where: { id: noteId }, + data: { + title: version.title, + content: version.content, + updatedAt: new Date(), + }, + select: { + id: true, + title: true, + content: true, + updatedAt: true, + }, + }) + + return updatedNote +}