'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, getHashColor } from '@/lib/utils' import { upsertNoteEmbedding } from '@/lib/embeddings' import { embeddingService } from '@/lib/ai/services/embedding.service' import { syncNoteLinksForNote } from '@/lib/notes/sync-note-links' 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 { getAISettings } from '@/app/actions/ai-settings' import { createNoteHistorySnapshot, getNoteHistoryMode, shouldCaptureHistorySnapshot, shouldCreateAutoSnapshot, } from '@/lib/note-history' import { NOTE_LIST_SELECT } from '@/lib/note-select' 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, }, }) } /** 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 }) // Batch lookup (1 query) then batch insert (1 query) — avoids N+1 const existingLabels = await prisma.label.findMany({ where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } }, select: { name: true }, }) const existingLower = new Set(existingLabels.map(l => l.name.toLowerCase())) const toCreate = trimmedNames.filter(n => !existingLower.has(n.toLowerCase())) if (toCreate.length > 0) { await prisma.label.createMany({ data: toCreate.map(name => ({ userId, name, color: getHashColor(name), notebookId: nbScope })), skipDuplicates: true, }) } 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. */ export 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 [] } } // 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('/home') } // 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 { embedding } = await embeddingService.generateNoteEmbedding(data.title, content) if (embedding) { await upsertNoteEmbedding(noteId, embedding) } } 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('/home') } } } } catch (error) { console.error('[BG] Auto-labeling failed:', error) } } else { console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId) } })().catch(e => console.error('[BG] Uncaught background error:', e)) 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; const title = data.title !== undefined ? data.title : oldNote?.title ?? null; (async () => { try { const { embedding } = await embeddingService.generateNoteEmbedding(title, content) if (embedding) { await upsertNoteEmbedding(noteId, embedding); } } 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) } if (data.content !== undefined) { syncNoteLinksForNote(id, session.user.id, data.content).catch(err => { console.error('[NoteLink] sync failed after updateNote:', err) }) } // 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('/home') } 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 } } // Toggle functions export async function togglePin( id: string, isPinned: boolean, options?: { skipRevalidation?: boolean } ) { return updateNote(id, { isPinned }, options) } export async function toggleArchive( id: string, isArchived: boolean, options?: { skipRevalidation?: boolean } ) { return updateNote(id, { isArchived }, options) } 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('/home') 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('/home') 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; const 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('/home') 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 } } }) for (const note of notesToSync) { if (!note.content) continue; try { const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content) if (embedding) { await upsertNoteEmbedding(note.id, embedding) 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('/home') // 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 } } // ─── Backward-compat delegating wrappers ──────────────────────────────────── // 'use server' files only allow `export async function` declarations. // Each wrapper is a thin pass-through to the dedicated sub-module. import { getNoteHistory as _getNoteHistory, restoreNoteVersion as _restoreNoteVersion, commitNoteHistory as _commitNoteHistory, deleteNoteHistoryEntry as _deleteNoteHistoryEntry, enableNoteHistory as _enableNoteHistory, } from './notes-history' import { deleteNote as _deleteNote, trashNote as _trashNote, restoreNote as _restoreNote, getTrashedNotes as _getTrashedNotes, permanentDeleteNote as _permanentDeleteNote, emptyTrash as _emptyTrash, removeImageFromNote as _removeImageFromNote, cleanupOrphanedImages as _cleanupOrphanedImages, getTrashCount as _getTrashCount, getTrashedNotebooks as _getTrashedNotebooks, } from './notes-trash' import { addCollaborator as _addCollaborator, removeCollaborator as _removeCollaborator, getNoteCollaborators as _getNoteCollaborators, getNoteAllUsers as _getNoteAllUsers, createShareRequest as _createShareRequest, getPendingShareRequests as _getPendingShareRequests, respondToShareRequest as _respondToShareRequest, getAcceptedSharedNotes as _getAcceptedSharedNotes, removeSharedNoteFromView as _removeSharedNoteFromView, getPendingShareCount as _getPendingShareCount, leaveSharedNote as _leaveSharedNote, } from './notes-sharing' // History export async function getNoteHistory(...args: Parameters) { return _getNoteHistory(...args) } export async function restoreNoteVersion(...args: Parameters) { return _restoreNoteVersion(...args) } export async function commitNoteHistory(...args: Parameters) { return _commitNoteHistory(...args) } export async function deleteNoteHistoryEntry(...args: Parameters) { return _deleteNoteHistoryEntry(...args) } export async function enableNoteHistory(...args: Parameters) { return _enableNoteHistory(...args) } // Trash & images export async function deleteNote(...args: Parameters) { return _deleteNote(...args) } export async function trashNote(...args: Parameters) { return _trashNote(...args) } export async function restoreNote(...args: Parameters) { return _restoreNote(...args) } export async function getTrashedNotes(...args: Parameters) { return _getTrashedNotes(...args) } export async function permanentDeleteNote(...args: Parameters) { return _permanentDeleteNote(...args) } export async function emptyTrash(...args: Parameters) { return _emptyTrash(...args) } export async function removeImageFromNote(...args: Parameters) { return _removeImageFromNote(...args) } export async function cleanupOrphanedImages(...args: Parameters) { return _cleanupOrphanedImages(...args) } export async function getTrashCount(...args: Parameters) { return _getTrashCount(...args) } export async function getTrashedNotebooks(...args: Parameters) { return _getTrashedNotebooks(...args) } // Sharing export async function addCollaborator(...args: Parameters) { return _addCollaborator(...args) } export async function removeCollaborator(...args: Parameters) { return _removeCollaborator(...args) } export async function getNoteCollaborators(...args: Parameters) { return _getNoteCollaborators(...args) } export async function getNoteAllUsers(...args: Parameters) { return _getNoteAllUsers(...args) } export async function createShareRequest(...args: Parameters) { return _createShareRequest(...args) } export async function getPendingShareRequests(...args: Parameters) { return _getPendingShareRequests(...args) } export async function respondToShareRequest(...args: Parameters) { return _respondToShareRequest(...args) } export async function getAcceptedSharedNotes(...args: Parameters) { return _getAcceptedSharedNotes(...args) } export async function removeSharedNoteFromView(...args: Parameters) { return _removeSharedNoteFromView(...args) } export async function getPendingShareCount(...args: Parameters) { return _getPendingShareCount(...args) } export async function leaveSharedNote(...args: Parameters) { return _leaveSharedNote(...args) }