import prisma from '@/lib/prisma' import { asArray } from '@/lib/utils' import type { NoteHistoryEntry } from '@/lib/types' const COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes const CONTENT_DIFF_THRESHOLD = 20 // characters const HISTORY_TRACKED_FIELDS = [ 'title', 'content', 'color', 'isPinned', 'isArchived', 'type', 'checkItems', 'labels', 'images', 'links', 'isMarkdown', 'size', 'notebookId', ] as const export function shouldCaptureHistorySnapshot(data: Record): boolean { return HISTORY_TRACKED_FIELDS.some((field) => field in data) } export async function isNoteHistoryEnabledForUser(userId: string): Promise { const settings = await prisma.userAISettings.findUnique({ where: { userId }, select: { noteHistory: true }, }) return settings?.noteHistory === true } export async function createNoteHistorySnapshot({ noteId, userId, reason, }: { noteId: string userId: string reason?: string }): Promise { const note = await prisma.note.findFirst({ where: { id: noteId, userId }, select: { id: true, userId: true, title: true, content: true, color: true, isPinned: true, isArchived: true, type: true, checkItems: true, labels: true, images: true, links: true, isMarkdown: true, size: true, notebookId: true, }, }) if (!note || !note.userId) return const ownerId = note.userId await prisma.$transaction(async (tx) => { const lastVersionEntry = await tx.noteHistory.findFirst({ where: { noteId }, orderBy: { version: 'desc' }, select: { version: true }, }) const nextVersion = ((lastVersionEntry?.version as number | undefined) ?? 0) + 1 await tx.noteHistory.create({ data: { noteId: note.id, userId: ownerId, version: nextVersion, reason: reason ?? null, title: note.title, content: note.content, color: note.color, isPinned: note.isPinned, isArchived: note.isArchived, type: note.type, checkItems: note.checkItems, labels: note.labels, images: note.images, links: note.links, isMarkdown: note.isMarkdown, size: note.size, notebookId: note.notebookId, }, }) }) } export function parseNoteHistoryEntry(entry: any): NoteHistoryEntry { return { id: entry.id, noteId: entry.noteId, userId: entry.userId, version: entry.version, reason: entry.reason ?? null, title: entry.title ?? null, content: entry.content, color: entry.color, isPinned: entry.isPinned, isArchived: entry.isArchived, type: entry.type, checkItems: asArray(entry.checkItems, null as any) ?? null, labels: asArray(entry.labels) || null, images: asArray(entry.images) || null, links: asArray(entry.links) || null, isMarkdown: entry.isMarkdown, size: entry.size, notebookId: entry.notebookId ?? null, createdAt: entry.createdAt, } } export async function getNoteHistoryMode(userId: string): Promise<'manual' | 'auto'> { const settings = await prisma.userAISettings.findUnique({ where: { userId }, select: { noteHistoryMode: true }, }) const mode = settings?.noteHistoryMode return mode === 'auto' ? 'auto' : 'manual' } const STRUCTURAL_FIELDS = ['color', 'isPinned', 'isArchived', 'labels', 'notebookId'] as const export async function shouldCreateAutoSnapshot(params: { noteId: string userId: string updateData: Record existingContent: string existingTitle: string | null }): Promise { const { noteId, userId, updateData, existingContent, existingTitle } = params // Structural changes (color, pin, archive, labels, notebook) always create a snapshot const hasStructuralChange = STRUCTURAL_FIELDS.some((f) => f in updateData) if (hasStructuralChange) return true // Content changes: check diff threshold const newContent = typeof updateData.content === 'string' ? updateData.content : null const newTitle = typeof updateData.title === 'string' ? updateData.title : null const contentChanged = newContent !== null const titleChanged = newTitle !== null if (!contentChanged && !titleChanged) return false // Check cooldown: find the most recent snapshot for this note const lastSnapshot = await prisma.noteHistory.findFirst({ where: { noteId }, orderBy: { createdAt: 'desc' }, select: { createdAt: true }, }) if (lastSnapshot) { const elapsed = Date.now() - new Date(lastSnapshot.createdAt).getTime() if (elapsed < COOLDOWN_MS) { // Within cooldown — only skip if the diff is trivial const contentDiff = contentChanged ? Math.abs((newContent as string).length - existingContent.length) : 0 const titleDiff = titleChanged ? Math.abs((newTitle ?? '').length - (existingTitle ?? '').length) : 0 if (contentDiff < CONTENT_DIFF_THRESHOLD && titleDiff === 0) { return false } } } return true }