All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m11s
Chat (AIChat floating widget): conversationId was never captured from the API response, so every message created a new conversation with no context. Now creates the conversation upfront before streaming (same pattern as ChatContainer) so the ID persists across messages. Note history: was stored globally in UserAISettings, so enabling history on one note enabled it for ALL notes. Now each Note has its own historyEnabled boolean field. The "Enable history" action only affects the specific note. A migration adds the column with default false. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2012 lines
59 KiB
TypeScript
2012 lines
59 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import prisma from '@/lib/prisma'
|
|
import { Note, CheckItem } from '@/lib/types'
|
|
import { auth } from '@/auth'
|
|
import { getAIProvider } from '@/lib/ai/factory'
|
|
import { parseNote as parseNoteUtil, cosineSimilarity, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
|
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
|
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.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,
|
|
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,
|
|
// embedding: false — volontairement omis (économise ~6KB JSON/note)
|
|
} 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<string>()
|
|
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<string>()
|
|
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' }
|
|
}
|
|
}
|
|
|
|
// 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 enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
|
if (!enabled) 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 },
|
|
})
|
|
if (!note) return []
|
|
|
|
const entries = await (prisma as any).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 enabled = await isNoteHistoryEnabledForUser(session.user.id)
|
|
if (!enabled) throw new Error('History is disabled')
|
|
|
|
const [note, historyEntry] = await Promise.all([
|
|
prisma.note.findFirst({
|
|
where: { id: noteId, userId: session.user.id },
|
|
select: { id: true, notebookId: true },
|
|
}),
|
|
(prisma as any).noteHistory.findFirst({
|
|
where: {
|
|
id: historyEntryId,
|
|
noteId,
|
|
userId: session.user.id,
|
|
},
|
|
}),
|
|
])
|
|
|
|
if (!note || !historyEntry) {
|
|
throw new Error('History entry not found')
|
|
}
|
|
|
|
const restored = await prisma.note.update({
|
|
where: { id: note.id, userId: session.user.id },
|
|
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(),
|
|
},
|
|
})
|
|
|
|
try {
|
|
await createNoteHistorySnapshot({
|
|
noteId: note.id,
|
|
userId: session.user.id,
|
|
reason: `restore:v${historyEntry.version}`,
|
|
})
|
|
} catch (snapshotError) {
|
|
console.error('[HISTORY] Failed to create snapshot after restore:', snapshotError)
|
|
}
|
|
|
|
revalidatePath('/')
|
|
revalidatePath(`/note/${note.id}`)
|
|
revalidatePath('/archive')
|
|
if (note.notebookId) revalidatePath(`/notebook/${note.notebookId}`)
|
|
if (historyEntry.notebookId && historyEntry.notebookId !== note.notebookId) {
|
|
revalidatePath(`/notebook/${historyEntry.notebookId}`)
|
|
}
|
|
|
|
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 as any).noteHistory.findFirst({
|
|
where: { id: historyEntryId, noteId, userId: session.user.id },
|
|
})
|
|
if (!entry) throw new Error('History entry not found')
|
|
|
|
await (prisma as any).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 },
|
|
})
|
|
}
|
|
|
|
// Search notes - DB-side filtering (fast) with optional semantic search
|
|
// Supports contextual search within notebook (IA5)
|
|
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
// If query empty, return all notes
|
|
if (!query || !query.trim()) {
|
|
return await getAllNotes();
|
|
}
|
|
|
|
// If semantic search is requested, use the full implementation
|
|
if (useSemantic) {
|
|
return await semanticSearch(query, session.user.id, notebookId);
|
|
}
|
|
|
|
// DB-side keyword search using LIKE — much faster than loading all notes in memory
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
OR: [
|
|
{ title: { contains: query } },
|
|
{ content: { contains: query } },
|
|
{ labels: { contains: query } },
|
|
],
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
});
|
|
|
|
return notes.map(parseNote);
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Semantic search with AI embeddings - SIMPLE VERSION
|
|
// Supports contextual search within notebook (IA5)
|
|
async function semanticSearch(query: string, userId: string, notebookId?: string) {
|
|
const allNotes = await prisma.note.findMany({
|
|
where: {
|
|
userId: userId,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
...(notebookId !== undefined ? { notebookId } : {})
|
|
},
|
|
include: { noteEmbedding: true }
|
|
});
|
|
|
|
const queryLower = query.toLowerCase().trim();
|
|
|
|
// Get query embedding
|
|
let queryEmbedding: number[] | null = null;
|
|
try {
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
queryEmbedding = await provider.getEmbeddings(query);
|
|
} catch (e) {
|
|
console.error('Failed to generate query embedding:', e);
|
|
// Fallback to simple keyword search
|
|
queryEmbedding = null;
|
|
}
|
|
|
|
// Filter notes: keyword match OR semantic match (threshold 30%)
|
|
const results = allNotes.map(note => {
|
|
const title = (note.title || '').toLowerCase();
|
|
const content = note.content.toLowerCase();
|
|
const labels = note.labels ? JSON.parse(note.labels) : [];
|
|
|
|
// Keyword match
|
|
const keywordMatch = title.includes(queryLower) ||
|
|
content.includes(queryLower) ||
|
|
labels.some((l: string) => l.toLowerCase().includes(queryLower));
|
|
|
|
// Semantic match (if embedding available)
|
|
let semanticMatch = false;
|
|
let similarity = 0;
|
|
if (queryEmbedding && note.noteEmbedding?.embedding) {
|
|
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.noteEmbedding.embedding));
|
|
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
|
|
}
|
|
|
|
return {
|
|
note,
|
|
keywordMatch,
|
|
semanticMatch,
|
|
similarity
|
|
};
|
|
}).filter(r => r.keywordMatch || r.semanticMatch);
|
|
|
|
// Parse and add match info
|
|
return results.map(r => {
|
|
const parsed = parseNote(r.note);
|
|
|
|
// Determine match type
|
|
let matchType: 'exact' | 'related' | null = null;
|
|
if (r.semanticMatch) {
|
|
matchType = 'related';
|
|
} else if (r.keywordMatch) {
|
|
matchType = 'exact';
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
matchType
|
|
};
|
|
});
|
|
}
|
|
|
|
// Create a new note
|
|
export async function createNote(data: {
|
|
title?: string
|
|
content: string
|
|
color?: string
|
|
type?: 'text' | 'checklist'
|
|
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 // Assign note to a notebook if provided
|
|
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates
|
|
}) {
|
|
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 || 'text',
|
|
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 {
|
|
// Background task 1: Generate embedding
|
|
const bgConfig = await getSystemConfig()
|
|
const provider = getAIProvider(bgConfig)
|
|
const embedding = await provider.getEmbeddings(content)
|
|
if (embedding) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: noteId },
|
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
|
update: { embedding: JSON.stringify(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('/')
|
|
}
|
|
}
|
|
}
|
|
} 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?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[] | null
|
|
labels?: string[] | null
|
|
images?: 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
|
|
}
|
|
}
|
|
|
|
// Generate embedding in background — don't block the update
|
|
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) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: noteId },
|
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
|
update: { embedding: JSON.stringify(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 ('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']
|
|
const isContentChange = contentFields.some(field => field in data)
|
|
if (isContentChange && !options?.skipContentTimestamp) {
|
|
updateData.contentUpdatedAt = new Date()
|
|
}
|
|
|
|
const note = await prisma.note.update({
|
|
where: { id, userId: session.user.id },
|
|
data: updateData
|
|
})
|
|
|
|
// 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<string, unknown>)) {
|
|
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<string, unknown>,
|
|
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)
|
|
|
|
if (isStructuralChange && !options?.skipRevalidation) {
|
|
revalidatePath('/')
|
|
revalidatePath(`/note/${id}`)
|
|
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}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Fetch trashed notes with images before deleting
|
|
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 }
|
|
}
|
|
})
|
|
|
|
// Clean up image files for all deleted notes
|
|
for (const note of trashedNotes) {
|
|
const imageUrls = parseImageUrls(note.images)
|
|
if (imageUrls.length > 0) {
|
|
await cleanupNoteImages(note.id, imageUrls)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
return await prisma.note.count({
|
|
where: {
|
|
userId: session.user.id,
|
|
trashedAt: { not: null }
|
|
}
|
|
})
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
try {
|
|
const notes = await prisma.note.findMany({ select: { labels: true } })
|
|
const labelsSet = new Set<string>()
|
|
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<string>()
|
|
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<string>()
|
|
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) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: note.id },
|
|
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
|
|
update: { embedding: JSON.stringify(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) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
|
|
const [ownNotes, acceptedShares] = await Promise.all([
|
|
prisma.note.findMany({
|
|
where: {
|
|
userId,
|
|
trashedAt: null,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
}),
|
|
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;
|
|
}
|
|
}
|