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
This commit is contained in:
2026-03-22 17:42:47 -03:00
parent e57927e37d
commit 9ed7d8acec
7 changed files with 315 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@@ -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:
<Edit className="h-4 w-4 mr-1" /> Editar
</Button>
</Link>
<VersionHistory noteId={note.id} />
<DeleteNoteButton noteId={note.id} noteTitle={note.title} />
</div>
</div>

View File

@@ -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<Version[]>([])
const [loading, setLoading] = useState(false)
const [restoring, setRestoring] = useState<string | null>(null)
const [confirmRestore, setConfirmRestore] = useState<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<History className="h-4 w-4 mr-1" /> Historial
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>Historial de versiones</DialogTitle>
<DialogDescription>
Revisa y restaura versiones anteriores de esta nota
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto py-4">
{loading ? (
<div className="text-center py-8 text-gray-500">Cargando versiones...</div>
) : versions.length === 0 ? (
<div className="text-center py-8 text-gray-500">No hay versiones guardadas</div>
) : (
<div className="space-y-3">
{versions.map((version) => (
<div
key={version.id}
className="flex items-start justify-between gap-4 p-3 border rounded-lg hover:bg-gray-50"
>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">
{formatDate(version.createdAt)}
</div>
<div className="text-gray-600 text-sm truncate">
{version.title}
</div>
<div className="text-gray-400 text-xs mt-1 truncate">
{version.content.substring(0, 50)}...
</div>
</div>
{confirmRestore === version.id ? (
<div className="flex gap-2 items-center">
<span className="text-xs text-gray-500">¿Restaurar?</span>
<Button
size="sm"
variant="destructive"
onClick={() => handleRestore(version.id)}
disabled={restoring === version.id}
>
{restoring === version.id ? '...' : 'Sí'}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmRestore(null)}
>
No
</Button>
</div>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => setConfirmRestore(version.id)}
title="Restaurar esta versión"
>
<RotateCcw className="h-4 w-4" />
</Button>
)}
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

96
src/lib/versions.ts Normal file
View File

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