Files
Momento/memento-note/app/actions/notes.ts
sepehr 153c921960
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
fix: comprehensive i18n — replace hardcoded French/English strings with t() calls
Replaced ~100+ hardcoded French and English text strings across 30+ components
with proper i18n t() calls. Added 57 new translation keys to all 15 locale files
(ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh).

Key changes:
- contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders)
- ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history)
- note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist)
- lab-skeleton.tsx: French loading text → t()
- admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed
- New AI chat component, agent cards, sidebar, settings panel i18n cleanup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:14:45 +02:00

1767 lines
51 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'
/**
* 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)
}
// 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
}
/**
* Sync Label rows with Note.labels.
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
*/
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
try {
const nbScope = notebookId ?? null
// 1. Bulk-upsert les nouveaux labels via upsert en transaction
if (noteLabels.length > 0) {
const trimmedNames = [...new Set(
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
)]
if (trimmedNames.length > 0) {
await prisma.$transaction(
trimmedNames.map(name =>
prisma.label.upsert({
where: { notebookId_name: { notebookId: nbScope ?? '', name } as any },
update: {},
create: {
userId,
name,
color: getHashColor(name),
notebookId: nbScope,
},
})
)
)
}
}
// 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur
const [allNotes, allLabels] = await Promise.all([
prisma.note.findMany({
where: { userId },
select: {
notebookId: true,
labels: true,
labelRelations: { select: { name: true } },
},
}),
prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
])
const usedLabelsSet = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (key) usedLabelsSet.add(key)
}
}
// 3. Supprimer les labels orphelins
const orphanIds = allLabels
.filter(label => {
const key = labelScopeKey(label.notebookId, label.name)
return key && !usedLabelsSet.has(key)
})
.map(label => label.id)
if (orphanIds.length > 0) {
// Dissocier les relations avant la suppression
await prisma.label.updateMany({
where: { id: { in: orphanIds } },
data: {} // Nécessaire pour trigger le middleware
})
// Supprimer en une seule requête
await prisma.label.deleteMany({
where: { id: { in: orphanIds } }
})
}
} catch (error) {
console.error('Fatal error in syncLabels:', error)
}
}
/** 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 syncLabels(session.user.id, labels, newNotebookId)
}
// 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 []
}
}
// 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 {
// 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: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
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 user-provided labels immediately (étiquettes rattachées au carnet de la note)
if (data.labels && data.labels.length > 0) {
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
}
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) {
await prisma.note.update({
where: { id: noteId },
data: { labels: JSON.stringify(appliedLabels) }
})
await syncLabels(userId, appliedLabels, notebookId ?? null)
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 }
})
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
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
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 Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
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 syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null)
}
// 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.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;
}
}