'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, validateEmbedding, 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' // Wrapper for parseNote that validates embeddings function parseNote(dbNote: any): Note { const note = parseNoteUtil(dbNote) // Validate embedding if present if (note.embedding && Array.isArray(note.embedding)) { const validation = validateEmbedding(note.embedding) if (!validation.valid) { return { ...note, embedding: null } } } return note } // 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 + labelRelations. * Les étiquettes d’un carnet doivent avoir le même notebookId que les notes (liste latérale / filtres). */ async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) { try { const nbScope = notebookId ?? null if (noteLabels.length > 0) { let scoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) for (const labelName of noteLabels) { if (!labelName?.trim()) continue const trimmed = labelName.trim() const exists = scoped.some( l => (l.notebookId ?? null) === nbScope && l.name.toLowerCase() === trimmed.toLowerCase() ) if (exists) continue try { const created = await prisma.label.create({ data: { userId, name: trimmed, color: getHashColor(trimmed), notebookId: nbScope, }, }) scoped.push(created) } catch (e: any) { if (e.code !== 'P2002') { console.error(`[SYNC] Failed to create label "${trimmed}":`, e) } scoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) } } } const allNotes = await prisma.note.findMany({ where: { userId }, select: { notebookId: true, labels: true, labelRelations: { select: { name: true } }, }, }) const usedLabelsSet = new Set() for (const note of allNotes) { for (const name of collectLabelNamesFromNote(note)) { const key = labelScopeKey(note.notebookId, name) if (key) usedLabelsSet.add(key) } } const allLabels = await prisma.label.findMany({ where: { userId } }) for (const label of allLabels) { const key = labelScopeKey(label.notebookId, label.name) if (!key || usedLabelsSet.has(key)) continue try { await prisma.label.update({ where: { id: label.id }, data: { notes: { set: [] } }, }) await prisma.label.delete({ where: { id: label.id } }) } catch (e) { console.error('[SYNC] Failed to delete orphan label:', e) } } } 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, ...(includeArchived ? {} : { isArchived: false }), }, 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, isArchived: false, reminder: { not: null } }, 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 }, orderBy: { updatedAt: 'desc' } }) return notes.map(parseNote) } catch (error) { console.error('Error fetching archived notes:', error) return [] } } // Search notes - SIMPLE AND EFFECTIVE // 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); // NEW: Pass notebookId for contextual search (IA5) } // Get all notes const allNotes = await prisma.note.findMany({ where: { userId: session.user.id, isArchived: false } }); const queryLower = query.toLowerCase().trim(); // SIMPLE FILTER: check if query is in title OR content OR labels const filteredNotes = allNotes.filter(note => { const title = (note.title || '').toLowerCase(); const content = note.content.toLowerCase(); const labels = note.labels ? JSON.parse(note.labels) : []; // Check if query exists in title, content, or any label return title.includes(queryLower) || content.includes(queryLower) || labels.some((label: string) => label.toLowerCase().includes(queryLower)); }); return filteredNotes.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, ...(notebookId !== undefined ? { notebookId } : {}) // NEW: Filter by notebook (IA5) } }); 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.embedding) { similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.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 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', embedding: null, // Generated in background autoGenerated: data.autoGenerated || 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 provider = getAIProvider(await getSystemConfig()) const embedding = await provider.getEmbeddings(content) if (embedding) { await prisma.note.update({ where: { id: noteId }, data: { 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 autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true) const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70) if (autoLabelingEnabled) { const suggestions = await contextualAutoTagService.suggestLabels( content, notebookId, userId ) 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) } } })() 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 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.note.update({ where: { id: noteId }, data: { 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 } } // Delete a note export async function deleteNote(id: string) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { await prisma.note.delete({ where: { id, userId: session.user.id } }) // Sync labels with empty array to trigger cleanup of any orphans // The syncLabels function will scan all remaining notes and clean up unused labels await syncLabels(session.user.id, []) revalidatePath('/') return { success: true } } catch (error) { console.error('Error deleting note:', error) throw new Error('Failed to delete note') } } // 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 }) } // 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() 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 }, 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 { const updates = ids.map((id: string, index: number) => prisma.note.update({ where: { id, userId }, data: { order: index } }) ) await prisma.$transaction(updates) return { success: true } } catch (error) { throw new Error('Failed to update order') } } // Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels) export async function cleanupAllOrphans() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; let createdCount = 0; let deletedCount = 0; let errors: any[] = []; try { const allNotes = await prisma.note.findMany({ where: { userId }, select: { notebookId: true, labels: true, labelRelations: { select: { name: true } }, }, }) const usedSet = new Set() for (const note of allNotes) { for (const name of collectLabelNamesFromNote(note)) { const key = labelScopeKey(note.notebookId, name) if (key) usedSet.add(key) } } let allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) const ensuredPairs = new Set() for (const note of allNotes) { for (const name of collectLabelNamesFromNote(note)) { const key = labelScopeKey(note.notebookId, name) if (!key || ensuredPairs.has(key)) continue ensuredPairs.add(key) const trimmed = name.trim() const nb = note.notebookId ?? null const exists = allScoped.some( l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase() ) if (exists) continue try { const created = await prisma.label.create({ data: { userId, name: trimmed, color: getHashColor(trimmed), notebookId: nb, }, }) allScoped.push(created) createdCount++ } catch (e: any) { console.error(`Failed to create label:`, e) errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code }) allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) } } } allScoped = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true, notebookId: true }, }) for (const label of allScoped) { const key = labelScopeKey(label.notebookId, label.name) if (!key || usedSet.has(key)) continue try { await prisma.label.update({ where: { id: label.id }, data: { notes: { set: [] } }, }) await prisma.label.delete({ where: { id: label.id } }) deletedCount++ } catch (e: any) { console.error(`Failed to delete orphan ${label.id}:`, e) errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code }) } } revalidatePath('/') revalidatePath('/settings') return { success: true, created: createdCount, deleted: deletedCount, errors, message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}` } } catch (error) { console.error('[CLEANUP] Fatal error:', error); throw new Error('Failed to cleanup database') } } export async function syncAllEmbeddings() { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); const userId = session.user.id; let updatedCount = 0; try { const notesToSync = await prisma.note.findMany({ where: { userId, embedding: 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.note.update({ where: { id: note.id }, data: { 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 { // Get user's own notes const ownNotes = await prisma.note.findMany({ where: { userId: userId, ...(includeArchived ? {} : { isArchived: false }), }, orderBy: [ { isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' } ] }) // Get notes shared with user via NoteShare (accepted only) const acceptedShares = await prisma.noteShare.findMany({ where: { userId: userId, status: 'accepted' }, include: { note: true } }) const sharedNotes = acceptedShares .map(share => share.note) .filter(note => includeArchived || !note.isArchived) const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)] // Derive pinned and recent notes const pinned = allNotes.filter((note: Note) => note.isPinned) const sevenDaysAgo = new Date() sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7) sevenDaysAgo.setHours(0, 0, 0, 0) const recent = allNotes .filter((note: Note) => { return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo }) .sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime()) .slice(0, 3) return allNotes } 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, ...(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, 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') } if (note.userId !== session.user.id) { 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; } }