'use server' import { revalidatePath } from 'next/cache' import prisma from '@/lib/prisma' import { Note, CheckItem, NoteType } from '@/lib/types' import { auth } from '@/auth' import { getAIProvider } from '@/lib/ai/factory' import { parseNote as parseNoteUtil } from '@/lib/utils' import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config' import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service' import { semanticSearchService } from '@/lib/ai/services/semantic-search.service' import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup' import { getAISettings } from '@/app/actions/ai-settings' import { createNoteHistorySnapshot, getNoteHistoryMode, isNoteHistoryEnabledForUser, parseNoteHistoryEntry, shouldCaptureHistorySnapshot, shouldCreateAutoSnapshot, } from '@/lib/note-history' /** * Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note). * L'embedding ne charge que pour la recherche sémantique. */ const NOTE_LIST_SELECT = { id: true, title: true, content: true, color: true, isPinned: true, isArchived: true, trashedAt: true, type: true, dismissedFromRecent: true, checkItems: true, labels: true, images: true, illustrationSvg: true, links: true, reminder: true, isReminderDone: true, reminderRecurrence: true, reminderLocation: true, isMarkdown: true, size: true, sharedWith: true, userId: true, order: true, notebookId: true, createdAt: true, updatedAt: true, contentUpdatedAt: true, autoGenerated: true, aiProvider: true, aiConfidence: true, language: true, languageConfidence: true, lastAiAnalysis: true, historyEnabled: true, } as const // Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table) function parseNote(dbNote: any): Note { return parseNoteUtil(dbNote) } async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) { const fallbackEmail = `user-${sessionUser.id}@local.momento` const safeEmail = sessionUser.email || fallbackEmail await prisma.user.upsert({ where: { id: sessionUser.id }, update: { ...(sessionUser.email ? { email: sessionUser.email } : {}), ...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}), }, create: { id: sessionUser.id, email: safeEmail, name: sessionUser.name || null, }, }) } // Helper to get hash color for labels (copied from utils) function getHashColor(name: string): string { const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray'] let hash = 0 for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash) } return colors[Math.abs(hash) % colors.length] } /** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */ function labelScopeKey(notebookId: string | null | undefined, rawName: string): string { const name = rawName.trim().toLowerCase() if (!name) return '' const nb = notebookId ?? '' return `${nb}\u0000${name}` } function collectLabelNamesFromNote(note: { labels: string | null labelRelations?: { name: string }[] }): string[] { const names: string[] = [] if (note.labels) { try { const parsed: unknown = JSON.parse(note.labels) if (Array.isArray(parsed)) { for (const l of parsed) { if (typeof l === 'string' && l.trim()) names.push(l.trim()) } } } catch (e) { console.error('[SYNC] Failed to parse labels:', e) } } for (const rel of note.labelRelations ?? []) { if (rel.name?.trim()) names.push(rel.name.trim()) } return names } /** * Upsert Label rows and return their IDs. * No orphan cleanup — labels are only deleted via the label management dialog. */ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> { try { const nbScope = notebookId ?? null if (noteLabels.length > 0) { // Deduplicate case-insensitively, keep original case const seen = new Set() const trimmedNames = noteLabels .map(name => name?.trim()) .filter((n): n is string => Boolean(n)) .filter(n => { const key = n.toLowerCase() if (seen.has(key)) return false seen.add(key) return true }) for (const name of trimmedNames) { // Case-insensitive find on PostgreSQL const existing = await prisma.label.findFirst({ where: { userId, name: { equals: name, mode: 'insensitive' }, notebookId: nbScope }, }) if (!existing) { await prisma.label.create({ data: { userId, name, color: getHashColor(name), notebookId: nbScope }, }) } } if (trimmedNames.length === 0) return [] // Search with original case (case-insensitive on PostgreSQL) return prisma.label.findMany({ where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } }, select: { id: true, name: true }, }) } return [] } catch (error) { console.error('Fatal error in syncLabels:', error) return [] } } /** Sync both Note.labels (JSON) AND labelRelations for a single note. * Also cleans up orphan labels in the same notebook scope. */ async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) { const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))] const labelRows = await syncLabels(userId, uniqueNames, notebookId) const labelIds = labelRows.map(l => l.id) await prisma.note.update({ where: { id: noteId }, data: { labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null, labelRelations: { set: labelIds.map(id => ({ id })) }, }, }) // Clean up orphan labels: labels in this notebook scope that are no longer // referenced by any note (neither via JSON nor via labelRelations) if (notebookId !== null) { const allLabels = await prisma.label.findMany({ where: { notebookId, userId }, select: { id: true, name: true }, }) if (allLabels.length > 0) { const labelIdsSet = new Set(allLabels.map(l => l.id)) // Find notes in this notebook that have any label relation const notesWithLabels = await prisma.note.findMany({ where: { notebookId, userId, labelRelations: { some: { id: { in: Array.from(labelIdsSet) } } }, }, select: { labels: true }, }) // Collect all label names still in use via JSON const namesInUse = new Set() for (const n of notesWithLabels) { if (n.labels) { try { const parsed = JSON.parse(n.labels as string) if (Array.isArray(parsed)) { parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase())) } } catch { } } } // Delete labels not in use const orphans = allLabels.filter(l => !namesInUse.has(l.name.toLowerCase())) if (orphans.length > 0) { await prisma.label.deleteMany({ where: { id: { in: orphans.map(l => l.id) } }, }) } } } } /** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */ export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) { const session = await auth() if (!session?.user?.id) return const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { labels: true }, }) if (!note) return let labels: string[] = [] if (note.labels) { try { const raw = JSON.parse(note.labels) as unknown if (Array.isArray(raw)) { labels = raw.filter((x): x is string => typeof x === 'string') } } catch { /* ignore */ } } await syncNoteLabels(noteId, labels, newNotebookId, session.user.id) } // Get all notes (non-archived by default) export async function getNotes(includeArchived = false) { const session = await auth(); if (!session?.user?.id) return []; try { const notes = await prisma.note.findMany({ where: { userId: session.user.id, trashedAt: null, ...(includeArchived ? {} : { isArchived: false }), }, select: NOTE_LIST_SELECT, orderBy: [ { isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' } ] }) return notes.map(parseNote) } catch (error) { console.error('Error fetching notes:', error) return [] } } // Get notes with reminders (upcoming, overdue, done) export async function getNotesWithReminders() { const session = await auth(); if (!session?.user?.id) return []; try { const notes = await prisma.note.findMany({ where: { userId: session.user.id, trashedAt: null, isArchived: false, reminder: { not: null } }, select: NOTE_LIST_SELECT, orderBy: { reminder: 'asc' } }) return notes.map(parseNote) } catch (error) { console.error('Error fetching notes with reminders:', error) return [] } } // Mark a reminder as done / undone export async function toggleReminderDone(noteId: string, done: boolean) { const session = await auth(); if (!session?.user?.id) return { error: 'Unauthorized' } try { await prisma.note.update({ where: { id: noteId, userId: session.user.id }, data: { isReminderDone: done } }) revalidatePath('/reminders') return { success: true } } catch (error) { console.error('Error toggling reminder done:', error) return { error: 'Failed to update reminder' } } } // Clear completed reminders (set reminder to null for done reminders) export async function clearCompletedReminders() { const session = await auth(); if (!session?.user?.id) return { error: 'Unauthorized' } try { await prisma.note.updateMany({ where: { userId: session.user.id, isReminderDone: true, reminder: { not: null }, }, data: { reminder: null, isReminderDone: false, }, }) revalidatePath('/reminders') return { success: true } } catch (error) { console.error('Error clearing completed reminders:', error) return { error: 'Failed to clear reminders' } } } // Get archived notes only export async function getArchivedNotes() { const session = await auth(); if (!session?.user?.id) return []; try { const notes = await prisma.note.findMany({ where: { userId: session.user.id, isArchived: true, trashedAt: null }, select: NOTE_LIST_SELECT, orderBy: { updatedAt: 'desc' } }) return notes.map(parseNote) } catch (error) { console.error('Error fetching archived notes:', error) return [] } } export async function getNoteHistory(noteId: string, limit = 30) { const session = await auth() if (!session?.user?.id) return [] const clampedLimit = Math.min(Math.max(limit, 1), 100) const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true, historyEnabled: true }, }) // History not found or not enabled on this note if (!note || !note.historyEnabled) return [] const entries = await prisma.noteHistory.findMany({ where: { noteId: note.id, userId: session.user.id }, orderBy: { createdAt: 'desc' }, take: clampedLimit, }) return entries.map(parseNoteHistoryEntry) } export async function restoreNoteVersion(noteId: string, historyEntryId: string) { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') const [note, historyEntry] = await Promise.all([ prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true, notebookId: true, historyEnabled: true }, }), prisma.noteHistory.findFirst({ where: { id: historyEntryId, noteId, userId: session.user.id, }, }), ]) if (!note || !note.historyEnabled) throw new Error('History is disabled for this note') if (!historyEntry) throw new Error('History entry not found') const userId = session.user.id const restored = await prisma.note.update({ where: { id: note.id, userId }, data: { title: historyEntry.title, content: historyEntry.content, color: historyEntry.color, isPinned: historyEntry.isPinned, isArchived: historyEntry.isArchived, type: historyEntry.type, checkItems: historyEntry.checkItems, labels: historyEntry.labels, images: historyEntry.images, links: historyEntry.links, isMarkdown: historyEntry.isMarkdown, size: historyEntry.size, notebookId: historyEntry.notebookId, contentUpdatedAt: new Date(), }, }) revalidatePath('/') return parseNote(restored) } export async function commitNoteHistory(noteId: string) { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true, historyEnabled: true }, }) if (!note) throw new Error('Note not found') if (!note.historyEnabled) throw new Error('History is disabled for this note') await createNoteHistorySnapshot({ noteId: note.id, userId: session.user.id, reason: 'manual-commit', }) } export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') const entry = await prisma.noteHistory.findFirst({ where: { id: historyEntryId, noteId, userId: session.user.id }, }) if (!entry) throw new Error('History entry not found') await prisma.noteHistory.delete({ where: { id: historyEntryId }, }) } export async function enableNoteHistory(noteId: string) { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true }, }) if (!note) throw new Error('Note not found') await prisma.note.update({ where: { id: noteId }, data: { historyEnabled: true }, }) } // Unified hybrid search — always uses FTS + pgvector with RRF fusion. // Supports contextual search within notebook (IA5). export async function searchNotes(query: string, _useSemantic: boolean = true, notebookId?: string) { const session = await auth(); if (!session?.user?.id) return []; try { if (!query || !query.trim()) { return await getAllNotes(); } const results = await semanticSearchService.searchAsUser(session.user.id, query, { limit: 50, threshold: 0.25, notebookId }); const noteIds = results.map(r => r.noteId); const notes = await prisma.note.findMany({ where: { id: { in: noteIds }, userId: session.user.id, isArchived: false, trashedAt: null, }, select: NOTE_LIST_SELECT, }); const orderMap = new Map(results.map((r, i) => [r.noteId, i])); const parsed = notes.map(parseNote); parsed.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999)); if (parsed.length > 0) { const topResult = results[0]; if (topResult) { parsed[0].matchType = topResult.matchType; parsed[0].searchScore = topResult.score; } } return parsed; } catch (error) { console.error('Search error:', error); return []; } } // Create a new note export async function createNote(data: { title?: string content: string color?: string type?: NoteType checkItems?: CheckItem[] labels?: string[] images?: string[] links?: any[] isArchived?: boolean reminder?: Date | null isMarkdown?: boolean size?: 'small' | 'medium' | 'large' autoGenerated?: boolean aiProvider?: string notebookId?: string | undefined skipRevalidation?: boolean }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { // Defensive guard: after DB reset/migration, auth session can exist while User row is missing. // Recreate user row to avoid Note_userId_fkey failures. await ensureSessionUserExists({ id: session.user.id, email: session.user.email, name: session.user.name, }) // Save note to DB immediately (fast!) — AI operations run in background after const note = await prisma.note.create({ data: { userId: session.user.id, title: data.title || null, content: data.content, color: data.color || 'default', type: data.type || 'richtext', checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null, labels: null, // set by syncNoteLabels below images: data.images ? JSON.stringify(data.images) : null, links: data.links ? JSON.stringify(data.links) : null, isArchived: data.isArchived || false, reminder: data.reminder || null, isMarkdown: data.isMarkdown || false, size: data.size || 'small', autoGenerated: data.autoGenerated || null, aiProvider: data.aiProvider || null, notebookId: data.notebookId || null, } }) // Sync labels (JSON + labelRelations + Label rows) in one call if (data.labels && data.labels.length > 0) { await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id) } try { // New notes start with historyEnabled=false (schema default), // so no initial snapshot is needed here. // History is enabled per-note via enableNoteHistory() action. } catch (snapshotError) { console.error('[HISTORY] Failed to create initial snapshot:', snapshotError) } if (!data.skipRevalidation) { // Revalidate main page (handles both inbox and notebook views via query params) revalidatePath('/') } // Fire-and-forget: run AI operations in background without blocking the response const userId = session.user.id const noteId = note.id const content = data.content const notebookId = data.notebookId const hasUserLabels = data.labels && data.labels.length > 0 // Use setImmediate-like pattern to not block the response ; (async () => { try { const bgConfig = await getSystemConfig() const provider = getAIProvider(bgConfig) const embedding = await provider.getEmbeddings(content) if (embedding) { const vecStr = `[${embedding.join(',')}]` await prisma.$executeRawUnsafe( `INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2::vector, now(), now()) ON CONFLICT ("noteId") DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`, noteId, vecStr ) } } catch (e) { console.error('[BG] Embedding generation failed:', e) } // Background task 2: Auto-labeling (only if no user labels and has notebook) if (!hasUserLabels && notebookId) { try { const userAISettings = await getAISettings(userId) const autoLabelingEnabled = userAISettings.autoLabeling !== false const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70) console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId) if (autoLabelingEnabled) { // Detect user's language from their existing notes for localized prompts let userLang = 'en' try { const langResult = await prisma.note.groupBy({ by: ['language'], where: { userId, language: { not: null } }, _count: true, orderBy: { _count: { language: 'desc' } }, take: 1, }) if (langResult.length > 0 && langResult[0].language) { userLang = langResult[0].language } } catch { } const suggestions = await contextualAutoTagService.suggestLabels( content, notebookId, userId, userLang ) console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label)) const appliedLabels = suggestions .filter(s => s.confidence >= autoLabelingConfidence) .map(s => s.label) if (appliedLabels.length > 0) { // Merge with existing labels const existing = await prisma.note.findUnique({ where: { id: noteId }, select: { labels: true }, }) let existingNames: string[] = [] if (existing?.labels) { try { const parsed = existing.labels as unknown existingNames = Array.isArray(parsed) ? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0) : [] } catch { existingNames = [] } } const merged = [...new Set([...existingNames, ...appliedLabels])] await syncNoteLabels(noteId, merged, notebookId ?? null, userId) if (!data.skipRevalidation) { revalidatePath('/') } } } } catch (error) { console.error('[BG] Auto-labeling failed:', error) } } else { console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId) } })() return parseNote(note) } catch (error) { console.error('Error creating note:', error) throw new Error('Failed to create note') } } // Update a note export async function updateNote(id: string, data: { title?: string | null content?: string color?: string isPinned?: boolean isArchived?: boolean type?: NoteType checkItems?: CheckItem[] | null labels?: string[] | null images?: string[] | null illustrationSvg?: string | null links?: any[] | null reminder?: Date | null isMarkdown?: boolean size?: 'small' | 'medium' | 'large' autoGenerated?: boolean | null aiProvider?: string | null notebookId?: string | null }, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const oldNote = await prisma.note.findUnique({ where: { id, userId: session.user.id }, select: { labels: true, notebookId: true, reminder: true, content: true, title: true, historyEnabled: true } }) const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : [] const oldNotebookId = oldNote?.notebookId const updateData: any = { ...data } // Reset isReminderDone only when reminder date actually changes (not on every save) if ('reminder' in data && data.reminder !== null) { const newTime = new Date(data.reminder as Date).getTime() const oldTime = oldNote?.reminder ? new Date(oldNote.reminder).getTime() : null if (newTime !== oldTime) { updateData.isReminderDone = false } } if (data.content !== undefined) { const noteId = id const content = data.content ; (async () => { try { const provider = getAIProvider(await getSystemConfig()); const embedding = await provider.getEmbeddings(content); if (embedding) { const vecStr = `[${embedding.join(',')}]` await prisma.$executeRawUnsafe( `INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2::vector, now(), now()) ON CONFLICT ("noteId") DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`, noteId, vecStr ) } } catch (e) { console.error('[BG] Embedding regeneration failed:', e); } })() } if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null // labels handled by syncNoteLabels below delete updateData.labels if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null if ('notebookId' in data) updateData.notebookId = data.notebookId // Explicitly handle size to ensure it propagates if ('size' in data && data.size) updateData.size = data.size // Only update contentUpdatedAt for actual content changes, NOT for property changes // (size, color, isPinned, isArchived are properties, not content) // skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg'] const isContentChange = contentFields.some(field => field in data) if (isContentChange && !options?.skipContentTimestamp) { updateData.contentUpdatedAt = new Date() } console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id) let note try { note = await prisma.note.update({ where: { id, userId: session.user.id }, data: updateData }) console.log('[updateNote] Succeeded, note id:', note?.id) } catch (dbError: any) { console.error('[updateNote] FAILED:', dbError.code, dbError.message) throw dbError } // Sync labels (JSON + labelRelations + Label rows) const notebookMoved = data.notebookId !== undefined && data.notebookId !== oldNotebookId if (data.labels !== undefined || notebookMoved) { const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels const effectiveNotebookId = data.notebookId !== undefined ? data.notebookId : oldNotebookId await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id) } try { const historyEnabled = oldNote?.historyEnabled === true if (historyEnabled && shouldCaptureHistorySnapshot(data as Record)) { const mode = await getNoteHistoryMode(session.user.id) if (mode === 'manual') { // No auto-snapshot in manual mode — user commits explicitly } else { const shouldAuto = await shouldCreateAutoSnapshot({ noteId: id, userId: session.user.id, updateData: data as Record, existingContent: oldNote?.content ?? '', existingTitle: oldNote?.title ?? null, }) if (shouldAuto) { await createNoteHistorySnapshot({ noteId: id, userId: session.user.id, reason: 'update', }) } } } } catch (snapshotError) { console.error('[HISTORY] Failed to create snapshot after update:', snapshotError) } // Only revalidate for STRUCTURAL changes that affect the page layout/lists // Content edits (title, content, size, color) use optimistic UI — no refresh needed const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId'] const isStructuralChange = structuralFields.some(field => field in data) console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange) if (!options?.skipRevalidation) { try { revalidatePath(`/note/${id}`) } catch {} try { revalidatePath('/') } catch {} } if (isStructuralChange) { if (data.isArchived !== undefined) { revalidatePath('/archive') } if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) { if (oldNotebookId) { revalidatePath(`/notebook/${oldNotebookId}`) } if (data.notebookId) { revalidatePath(`/notebook/${data.notebookId}`) } } } if (data.labels !== undefined) { const refreshed = await prisma.note.findUnique({ where: { id } }) if (refreshed) return parseNote(refreshed) } return parseNote(note) } catch (error) { console.error('Error updating note:', error) throw error // Re-throw the REAL error, not a generic one } } // Soft-delete a note (move to trash) export async function deleteNote(id: string, options?: { skipRevalidation?: boolean }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { await prisma.note.update({ where: { id, userId: session.user.id }, data: { trashedAt: new Date() } }) if (!options?.skipRevalidation) { revalidatePath('/') } return { success: true } } catch (error) { console.error('Error deleting note:', error) throw new Error('Failed to delete note') } } // Trash actions export async function trashNote(id: string, options?: { skipRevalidation?: boolean }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { await prisma.note.update({ where: { id, userId: session.user.id }, data: { trashedAt: new Date() } }) if (!options?.skipRevalidation) { revalidatePath('/') } return { success: true } } catch (error) { console.error('Error trashing note:', error) throw new Error('Failed to trash note') } } export async function restoreNote(id: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { await prisma.note.update({ where: { id, userId: session.user.id }, data: { trashedAt: null } }) revalidatePath('/') revalidatePath('/trash') return { success: true } } catch (error) { console.error('Error restoring note:', error) throw new Error('Failed to restore note') } } export async function getTrashedNotes() { const session = await auth(); if (!session?.user?.id) return []; try { const notes = await prisma.note.findMany({ where: { userId: session.user.id, trashedAt: { not: null } }, select: NOTE_LIST_SELECT, orderBy: { trashedAt: 'desc' } }) return notes.map(parseNote) } catch (error) { console.error('Error fetching trashed notes:', error) return [] } } export async function permanentDeleteNote(id: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { // Fetch images before deleting so we can clean up files const note = await prisma.note.findUnique({ where: { id, userId: session.user.id }, select: { images: true } }) const imageUrls = parseImageUrls(note?.images ?? null) await prisma.note.delete({ where: { id, userId: session.user.id } }) // Clean up orphaned image files (safe: skips if referenced by other notes) if (imageUrls.length > 0) { await cleanupNoteImages(id, imageUrls) } await syncLabels(session.user.id, []) revalidatePath('/trash') revalidatePath('/') return { success: true } } catch (error) { console.error('Error permanently deleting note:', error) throw new Error('Failed to permanently delete note') } } export async function emptyTrash() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const trashedNotes = await prisma.note.findMany({ where: { userId: session.user.id, trashedAt: { not: null } }, select: { id: true, images: true } }) await prisma.note.deleteMany({ where: { userId: session.user.id, trashedAt: { not: null } } }) for (const note of trashedNotes) { const imageUrls = parseImageUrls(note.images) if (imageUrls.length > 0) { await cleanupNoteImages(note.id, imageUrls) } } await prisma.notebook.deleteMany({ where: { userId: session.user.id, trashedAt: { not: null } } }) await syncLabels(session.user.id, []) revalidatePath('/trash') revalidatePath('/') return { success: true } } catch (error) { console.error('Error emptying trash:', error) throw new Error('Failed to empty trash') } } export async function removeImageFromNote(noteId: string, imageIndex: number) { const session = await auth() if (!session?.user?.id) throw new Error('Unauthorized') try { const note = await prisma.note.findUnique({ where: { id: noteId, userId: session.user.id }, select: { images: true }, }) if (!note) throw new Error('Note not found') const imageUrls = parseImageUrls(note.images) if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index') const removedUrl = imageUrls[imageIndex] const newImages = imageUrls.filter((_, i) => i !== imageIndex) await prisma.note.update({ where: { id: noteId }, data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null }, }) // Clean up file if no other note references it await deleteImageFileSafely(removedUrl, noteId) return { success: true } } catch (error) { console.error('Error removing image:', error) throw new Error('Failed to remove image') } } export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) { const session = await auth() if (!session?.user?.id) return try { for (const url of imageUrls) { await deleteImageFileSafely(url, noteId) } } catch { // Silent — best-effort cleanup } } export async function getTrashCount() { const session = await auth(); if (!session?.user?.id) return 0; try { const [noteCount, notebookCount] = await Promise.all([ prisma.note.count({ where: { userId: session.user.id, trashedAt: { not: null } } }), prisma.notebook.count({ where: { userId: session.user.id, trashedAt: { not: null } } }) ]) return noteCount + notebookCount } catch { return 0 } } export async function getTrashedNotebooks() { const session = await auth(); if (!session?.user?.id) return []; try { return await prisma.notebook.findMany({ where: { userId: session.user.id, trashedAt: { not: null } }, include: { _count: { select: { notes: true } } }, orderBy: { trashedAt: 'desc' } }) } catch (error) { console.error('Error fetching trashed notebooks:', error) return [] } } // Toggle functions export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) } export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) } export async function updateColor(id: string, color: string) { return updateNote(id, { color }) } export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) } export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) } // Update note size WITHOUT revalidation - client uses optimistic updates export async function updateSize(id: string, size: 'small' | 'medium' | 'large') { const result = await updateNote(id, { size }) return result } // Get all unique labels export async function getAllLabels() { const session = await auth(); if (!session?.user?.id) return []; try { const notes = await prisma.note.findMany({ where: { userId: session.user.id }, select: { labels: true } }) const labelsSet = new Set() notes.forEach((note: any) => { const labels = note.labels ? JSON.parse(note.labels) : null if (labels) labels.forEach((label: string) => labelsSet.add(label)) }) return Array.from(labelsSet).sort() } catch (error) { console.error('Error fetching labels:', error) return [] } } // Reorder export async function reorderNotes(draggedId: string, targetId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } }) const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } }) if (!draggedNote || !targetNote) throw new Error('Notes not found') const allNotes = await prisma.note.findMany({ where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null }, orderBy: { order: 'asc' } }) const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId) const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId) reorderedNotes.splice(targetIndex, 0, draggedNote) const updates = reorderedNotes.map((note: any, index: number) => prisma.note.update({ where: { id: note.id }, data: { order: index } }) ) await prisma.$transaction(updates) revalidatePath('/') return { success: true } } catch (error) { throw new Error('Failed to reorder notes') } } export async function updateFullOrder(ids: string[]) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; try { const updates = ids.map((id: string, index: number) => prisma.note.update({ where: { id, userId }, data: { order: index } }) ) await prisma.$transaction(updates) revalidatePath('/') return { success: true } } catch (error) { throw new Error('Failed to update order') } } // Optimized version for drag & drop - no revalidation to prevent double refresh export async function updateFullOrderWithoutRevalidation(ids: string[]) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; try { // Verify all notes belong to the user before updating const notes = await prisma.note.findMany({ where: { id: { in: ids }, userId }, select: { id: true }, }) const ownedIds = new Set(notes.map(n => n.id)) const validIds = ids.filter(id => ownedIds.has(id)) const updates = validIds.map((id: string, index: number) => prisma.note.update({ where: { id }, data: { order: index } }) ) await prisma.$transaction(updates) return { success: true } } catch (error) { throw new Error('Failed to update order') } } // Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels) export async function cleanupAllOrphans() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; let createdCount = 0; let deletedCount = 0; let errors: any[] = []; try { const allNotes = await prisma.note.findMany({ where: { userId }, select: { notebookId: true, labels: true, labelRelations: { select: { name: true } }, }, }) const usedSet = new Set() for (const note of allNotes) { for (const name of collectLabelNamesFromNote(note)) { const key = labelScopeKey(note.notebookId, name) if (key) usedSet.add(key) } } let allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) const ensuredPairs = new Set() for (const note of allNotes) { for (const name of collectLabelNamesFromNote(note)) { const key = labelScopeKey(note.notebookId, name) if (!key || ensuredPairs.has(key)) continue ensuredPairs.add(key) const trimmed = name.trim() const nb = note.notebookId ?? null const exists = allScoped.some( l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase() ) if (exists) continue try { const created = await prisma.label.create({ data: { userId, name: trimmed, color: getHashColor(trimmed), notebookId: nb, }, }) allScoped.push(created) createdCount++ } catch (e: any) { console.error(`Failed to create label:`, e) errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code }) allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) } } } allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) for (const label of allScoped) { const key = labelScopeKey(label.notebookId, label.name) if (!key || usedSet.has(key)) continue try { await prisma.label.update({ where: { id: label.id }, data: { notes: { set: [] } }, }) await prisma.label.delete({ where: { id: label.id } }) deletedCount++ } catch (e: any) { console.error(`Failed to delete orphan ${label.id}:`, e) errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code }) } } revalidatePath('/') revalidatePath('/settings') return { success: true, created: createdCount, deleted: deletedCount, errors, message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}` } } catch (error) { console.error('[CLEANUP] Fatal error:', error); throw new Error('Failed to cleanup database') } } export async function syncAllEmbeddings() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; let updatedCount = 0; try { const notesToSync = await prisma.note.findMany({ where: { userId, trashedAt: null, noteEmbedding: { is: null } } }) const provider = getAIProvider(await getSystemConfig()); for (const note of notesToSync) { if (!note.content) continue; try { const embedding = await provider.getEmbeddings(note.content); if (embedding) { const vecStr = `[${embedding.join(',')}]` await prisma.$executeRawUnsafe( `INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt") VALUES (gen_random_uuid(), $1, $2::vector, now(), now()) ON CONFLICT ("noteId") DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`, note.id, vecStr ) updatedCount++; } } catch (e) { } } return { success: true, count: updatedCount } } catch (error: any) { throw new Error(`Failed to sync embeddings: ${error.message}`) } } // Get all notes including those shared with the user export async function getAllNotes(includeArchived = false, notebookId?: string) { const session = await auth(); if (!session?.user?.id) return []; const userId = session.user.id; try { const whereClause: any = { userId, trashedAt: null, ...(includeArchived ? {} : { isArchived: false }), ...(notebookId !== undefined ? { notebookId: notebookId || null } : {}), } const ownNotes = await prisma.note.findMany({ where: whereClause, select: NOTE_LIST_SELECT, orderBy: [ { isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' } ] }) if (notebookId) { return ownNotes.map(parseNote) } const [acceptedShares] = await Promise.all([ prisma.noteShare.findMany({ where: { userId, status: 'accepted' }, include: { note: { select: NOTE_LIST_SELECT } } }) ]) const sharedNotes = acceptedShares .map(share => share.note) .filter(note => includeArchived || !note.isArchived) .map(note => ({ ...note, _isShared: true })) return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)] } catch (error) { console.error('Error fetching notes:', error) return [] } } // Get pinned notes only export async function getPinnedNotes(notebookId?: string) { const session = await auth(); if (!session?.user?.id) return []; const userId = session.user.id; try { const notes = await prisma.note.findMany({ where: { userId: userId, isPinned: true, isArchived: false, trashedAt: null, ...(notebookId !== undefined ? { notebookId } : {}) }, orderBy: [ { order: 'asc' }, { updatedAt: 'desc' } ] }) return notes.map(parseNote) } catch (error) { console.error('Error fetching pinned notes:', error) return [] } } // Get recent notes (notes modified in the last 7 days) // Get recent notes (notes modified in the last 7 days) export async function getRecentNotes(limit: number = 3) { const session = await auth(); if (!session?.user?.id) return []; const userId = session.user.id; try { const sevenDaysAgo = new Date() sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day const notes = await prisma.note.findMany({ where: { userId: userId, contentUpdatedAt: { gte: sevenDaysAgo }, isArchived: false, trashedAt: null, dismissedFromRecent: false // Filter out dismissed notes }, orderBy: { contentUpdatedAt: 'desc' }, take: limit }) return notes.map(parseNote) } catch (error) { console.error('Error fetching recent notes:', error) return [] } } // Dismiss a note from Recent section export async function dismissFromRecent(id: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { await prisma.note.update({ where: { id, userId: session.user.id }, data: { dismissedFromRecent: true } }) // revalidatePath('/') // Removed to prevent immediate refill of the list return { success: true } } catch (error) { console.error('Error dismissing note from recent:', error) throw new Error('Failed to dismiss note') } } export async function getNoteById(noteId: string) { const session = await auth(); if (!session?.user?.id) return null; const userId = session.user.id; try { const note = await prisma.note.findFirst({ where: { id: noteId, OR: [ { userId: userId }, { shares: { some: { userId: userId, status: 'accepted' } } } ] } }) if (!note) return null return parseNote(note) } catch (error) { console.error('Error fetching note:', error) return null } } // Add a collaborator to a note (updated to use new share request system) export async function addCollaborator(noteId: string, userEmail: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { // Use the new share request system const result = await createShareRequest(noteId, userEmail, 'view'); // Get user info for response const targetUser = await prisma.user.findUnique({ where: { email: userEmail }, select: { id: true, name: true, email: true, image: true } }); if (!targetUser) { throw new Error('User not found') } return { success: true, user: targetUser } } catch (error: any) { console.error('Error adding collaborator:', error) throw error } } // Remove a collaborator from a note export async function removeCollaborator(noteId: string, userId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const note = await prisma.note.findUnique({ where: { id: noteId } }) if (!note) { throw new Error('Note not found') } if (note.userId !== session.user.id) { throw new Error('You can only manage collaborators on your own notes') } // Delete the NoteShare record (cascades to remove access) await prisma.noteShare.deleteMany({ where: { noteId, userId } }) // Don't revalidatePath here - it would close all open dialogs! // The UI will update via optimistic updates in the dialog return { success: true } } catch (error: any) { console.error('Error removing collaborator:', error) throw new Error(error.message || 'Failed to remove collaborator') } } // Get collaborators for a note export async function getNoteCollaborators(noteId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const note = await prisma.note.findUnique({ where: { id: noteId }, select: { userId: true } }) if (!note) { throw new Error('Note not found') } // Owner can always see collaborators // Shared users can also see collaborators if they have accepted access if (note.userId !== session.user.id) { const share = await prisma.noteShare.findUnique({ where: { noteId_userId: { noteId, userId: session.user.id } } }) if (!share || share.status !== 'accepted') { throw new Error('You do not have access to this note') } } // Get all users who have been shared this note (any status) const shares = await prisma.noteShare.findMany({ where: { noteId }, include: { user: { select: { id: true, name: true, email: true, image: true } } } }) return shares.map(share => share.user) } catch (error: any) { console.error('Error fetching collaborators:', error) throw new Error(error.message || 'Failed to fetch collaborators') } } // Get all users associated with a note (owner + collaborators) export async function getNoteAllUsers(noteId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const note = await prisma.note.findUnique({ where: { id: noteId }, select: { userId: true } }) if (!note) { throw new Error('Note not found') } // Check access via NoteShare const share = await prisma.noteShare.findUnique({ where: { noteId_userId: { noteId, userId: session.user.id } } }) const hasAccess = note.userId === session.user.id || (share && share.status === 'accepted') if (!hasAccess) { throw new Error('You do not have access to this note') } // Get owner const owner = await prisma.user.findUnique({ where: { id: note.userId! }, select: { id: true, name: true, email: true, image: true } }) if (!owner) { throw new Error('Owner not found') } // Get collaborators (accepted shares) const shares = await prisma.noteShare.findMany({ where: { noteId, status: 'accepted' }, include: { user: { select: { id: true, name: true, email: true, image: true } } } }) const collaborators = shares.map(share => share.user) // Return owner + collaborators return [owner, ...collaborators] } catch (error: any) { console.error('Error fetching note users:', error) throw new Error(error.message || 'Failed to fetch note users') } } // ============================================================ // NEW: Share Request System with Accept/Decline Workflow // ============================================================ // Create a share request (invite user) export async function createShareRequest(noteId: string, recipientEmail: string, permission: 'view' | 'comment' | 'edit' = 'view') { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { // Find note const note = await prisma.note.findUnique({ where: { id: noteId } }); if (!note) { throw new Error('Note not found'); } // Check ownership if (note.userId !== session.user.id) { throw new Error('Only the owner can share notes'); } // Find recipient by email const recipient = await prisma.user.findUnique({ where: { email: recipientEmail } }); if (!recipient) { throw new Error('User not found'); } // Don't share with yourself if (recipient.id === session.user.id) { throw new Error('You cannot share with yourself'); } // Check if share already exists const existingShare = await prisma.noteShare.findUnique({ where: { noteId_userId: { noteId, userId: recipient.id } } }); if (existingShare) { if (existingShare.status === 'declined') { // Reactivate declined share await prisma.noteShare.update({ where: { id: existingShare.id }, data: { status: 'pending', notifiedAt: new Date(), permission } }); } else if (existingShare.status === 'removed') { // Reactivate removed share await prisma.noteShare.update({ where: { id: existingShare.id }, data: { status: 'pending', notifiedAt: new Date(), permission } }); } else { throw new Error('Note already shared with this user'); } } else { // Create new share request await prisma.noteShare.create({ data: { noteId, userId: recipient.id, sharedBy: session.user.id, status: 'pending', permission, notifiedAt: new Date() } }); } // Don't revalidatePath here - it would close all open dialogs! // The UI will update via optimistic updates in the dialog return { success: true }; } catch (error: any) { console.error('Error creating share request:', error); throw error; } } // Get pending share requests for current user export async function getPendingShareRequests() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const pendingRequests = await prisma.noteShare.findMany({ where: { userId: session.user.id, status: 'pending' }, include: { note: { select: { id: true, title: true, content: true, color: true, createdAt: true } }, sharer: { select: { id: true, name: true, email: true, image: true } } }, orderBy: { createdAt: 'desc' } }); return pendingRequests; } catch (error: any) { console.error('Error fetching pending share requests:', error); throw error; } } // Respond to share request (accept/decline) export async function respondToShareRequest(shareId: string, action: 'accept' | 'decline') { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const share = await prisma.noteShare.findUnique({ where: { id: shareId }, include: { note: true, sharer: true } }); if (!share) { throw new Error('Share request not found'); } // Verify this share belongs to current user if (share.userId !== session.user.id) { throw new Error('Unauthorized'); } // Update share status const updatedShare = await prisma.noteShare.update({ where: { id: shareId }, data: { status: action === 'accept' ? 'accepted' : 'declined', respondedAt: new Date() }, include: { note: { select: { title: true } } } }); // Revalidate all relevant cache tags revalidatePath('/'); return { success: true, share: updatedShare }; } catch (error: any) { console.error('Error responding to share request:', error); throw error; } } // Get all accepted shares for current user (notes shared with me) export async function getAcceptedSharedNotes() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const acceptedShares = await prisma.noteShare.findMany({ where: { userId: session.user.id, status: 'accepted' }, include: { note: true }, orderBy: { createdAt: 'desc' } }); return acceptedShares.map(share => share.note); } catch (error: any) { console.error('Error fetching accepted shared notes:', error); throw error; } } // Remove share from my view (hide the note) export async function removeSharedNoteFromView(shareId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { const share = await prisma.noteShare.findUnique({ where: { id: shareId } }); if (!share) { throw new Error('Share not found'); } // Verify this share belongs to current user if (share.userId !== session.user.id) { throw new Error('Unauthorized'); } // Update status to 'removed' (note is hidden from user's view) await prisma.noteShare.update({ where: { id: shareId }, data: { status: 'removed' } }); revalidatePath('/'); return { success: true }; } catch (error: any) { console.error('Error removing shared note from view:', error); throw error; } } // Get notification count for pending shares export async function getPendingShareCount() { const session = await auth(); if (!session?.user?.id) return 0; try { const count = await prisma.noteShare.count({ where: { userId: session.user.id, status: 'pending' } }); return count; } catch (error) { console.error('Error getting pending share count:', error); return 0; } } // Leave a shared note (remove from my list) export async function leaveSharedNote(noteId: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { // Find the NoteShare record for this user and note const share = await prisma.noteShare.findUnique({ where: { noteId_userId: { noteId, userId: session.user.id } } }); if (!share) { throw new Error('Share not found'); } // Verify this share belongs to current user if (share.userId !== session.user.id) { throw new Error('Unauthorized'); } // Update status to 'removed' (note is hidden from user's view) await prisma.noteShare.update({ where: { id: share.id }, data: { status: 'removed' } }); revalidatePath('/'); return { success: true }; } catch (error: any) { console.error('Error leaving shared note:', error); throw error; } }