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:
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
23
src/app/api/notes/[id]/versions/[versionId]/route.ts
Normal file
23
src/app/api/notes/[id]/versions/[versionId]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
23
src/app/api/notes/[id]/versions/route.ts
Normal file
23
src/app/api/notes/[id]/versions/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
158
src/components/version-history.tsx
Normal file
158
src/components/version-history.tsx
Normal 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
96
src/lib/versions.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user