'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 { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils' import { getSystemConfig, getConfigNumber, SEARCH_DEFAULTS } from '@/lib/config' // Helper function to parse JSON strings from database function parseNote(dbNote: any): Note { // Parse embedding const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null // Validate embedding if present if (embedding && Array.isArray(embedding)) { const validation = validateEmbedding(embedding) if (!validation.valid) { console.warn(`[EMBEDDING_VALIDATION] Invalid embedding for note ${dbNote.id}:`, validation.issues.join(', ')) // Don't include invalid embedding in the returned note return { ...dbNote, checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null, labels: dbNote.labels ? JSON.parse(dbNote.labels) : null, images: dbNote.images ? JSON.parse(dbNote.images) : null, links: dbNote.links ? JSON.parse(dbNote.links) : null, embedding: null, // Exclude invalid embedding sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [], size: dbNote.size || 'small', } } } return { ...dbNote, checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null, labels: dbNote.labels ? JSON.parse(dbNote.labels) : null, images: dbNote.images ? JSON.parse(dbNote.images) : null, links: dbNote.links ? JSON.parse(dbNote.links) : null, embedding, sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [], size: dbNote.size || 'small', } } // 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] } // Comprehensive sync function for labels - ensures consistency between Note.labels and Label table async function syncLabels(userId: string, noteLabels: string[] = []) { try { // Step 1: Create Label records for any labels in notes that don't exist in Label table // Get all existing labels for this user to do case-insensitive check in JS const existingLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } }) // Create a map for case-insensitive lookup const existingLabelMap = new Map() existingLabels.forEach(label => { existingLabelMap.set(label.name.toLowerCase(), label.name) }) for (const labelName of noteLabels) { if (!labelName || labelName.trim() === '') continue const trimmedLabel = labelName.trim() const lowerLabel = trimmedLabel.toLowerCase() // Check if label already exists (case-insensitive) const existingName = existingLabelMap.get(lowerLabel) // If label doesn't exist, create it if (!existingName) { try { await prisma.label.create({ data: { userId, name: trimmedLabel, color: getHashColor(trimmedLabel) } }) console.log(`[SYNC] Created label: "${trimmedLabel}"`) // Add to map to prevent duplicates in same batch existingLabelMap.set(lowerLabel, trimmedLabel) } catch (e: any) { // Ignore unique constraint violations (race condition) if (e.code !== 'P2002') { console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e) } } } } // Step 2: Get ALL labels currently used in ALL user's notes const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } }) const usedLabelsSet = new Set() allNotes.forEach(note => { if (note.labels) { try { const parsedLabels: string[] = JSON.parse(note.labels) if (Array.isArray(parsedLabels)) { parsedLabels.forEach(l => { if (l && l.trim()) { usedLabelsSet.add(l.trim().toLowerCase()) } }) } } catch (e) { console.error('[SYNC] Failed to parse labels:', e) } } }) // Step 3: Delete orphan Label records (labels not in any note) const allLabels = await prisma.label.findMany({ where: { userId } }) for (const label of allLabels) { if (!usedLabelsSet.has(label.name.toLowerCase())) { try { await prisma.label.delete({ where: { id: label.id } }) console.log(`[SYNC] Deleted orphan label: "${label.name}"`) } catch (e) { console.error(`[SYNC] Failed to delete orphan label "${label.name}":`, e) } } } console.log(`[SYNC] Completed: ${noteLabels.length} note labels synced, ${usedLabelsSet.size} unique labels in use, ${allLabels.length - usedLabelsSet.size} orphans removed`) } catch (error) { console.error('[SYNC] Fatal error in syncLabels:', error) } } // 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 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 (Hybrid: Keyword + Semantic) export async function searchNotes(query: string) { const session = await auth(); if (!session?.user?.id) return []; try { if (!query.trim()) { return await getNotes() } // Load search configuration const semanticThreshold = await getConfigNumber('SEARCH_SEMANTIC_THRESHOLD', SEARCH_DEFAULTS.SEMANTIC_THRESHOLD); // Detect query type and get adaptive weights const queryType = detectQueryType(query); const weights = getSearchWeights(queryType); console.log(`[SEARCH] Query type: ${queryType}, weights: keyword=${weights.keywordWeight}x, semantic=${weights.semanticWeight}x`); // 1. 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); } // 3. Get ALL notes for processing // Note: With SQLite, we have to load notes to memory. // For larger datasets, we would need a proper Vector DB (pgvector/chroma) or SQLite extension (sqlite-vss). const allNotes = await prisma.note.findMany({ where: { userId: session.user.id, isArchived: false } }); const parsedNotes = allNotes.map(parseNote); const queryTerms = query.toLowerCase().split(/\s+/).filter(t => t.length > 0); // --- A. Calculate Scores independently --- // A1. Keyword Score const keywordScores = parsedNotes.map(note => { let score = 0; const title = note.title?.toLowerCase() || ''; const content = note.content.toLowerCase(); const labels = note.labels?.map(l => l.toLowerCase()) || []; queryTerms.forEach(term => { if (title.includes(term)) score += 3; // Title match weight if (content.includes(term)) score += 1; // Content match weight if (labels.some(l => l.includes(term))) score += 2; // Label match weight }); // Bonus for exact phrase match if (title.includes(query.toLowerCase())) score += 5; return { id: note.id, score }; }); // A2. Semantic Score const semanticScores = parsedNotes.map(note => { let score = 0; if (queryEmbedding && note.embedding) { score = cosineSimilarity(queryEmbedding, note.embedding); } return { id: note.id, score }; }); // --- B. Rank Lists independently --- // Sort descending by score const keywordRanking = [...keywordScores].sort((a, b) => b.score - a.score); const semanticRanking = [...semanticScores].sort((a, b) => b.score - a.score); // Map ID -> Rank (0-based index) const keywordRankMap = new Map(keywordRanking.map((item, index) => [item.id, index])); const semanticRankMap = new Map(semanticRanking.map((item, index) => [item.id, index])); // --- C. Reciprocal Rank Fusion (RRF) --- // RRF combines multiple ranked lists into a single ranking // Formula: score = Σ (1 / (k + rank)) for each list // // The k constant controls how much we penalize lower rankings: // - Lower k = more strict with low ranks (better for small datasets) // - Higher k = more lenient (better for large datasets) // // We use adaptive k based on total notes: k = max(20, totalNotes / 10) const k = calculateRRFK(parsedNotes.length); const rrfScores = parsedNotes.map(note => { const kwRank = keywordRankMap.get(note.id) ?? parsedNotes.length; const semRank = semanticRankMap.get(note.id) ?? parsedNotes.length; // Only count if there is *some* relevance const hasKeywordMatch = (keywordScores.find(s => s.id === note.id)?.score || 0) > 0; const hasSemanticMatch = (semanticScores.find(s => s.id === note.id)?.score || 0) > semanticThreshold; let rrf = 0; if (hasKeywordMatch) { // Apply adaptive weight to keyword score rrf += (1 / (k + kwRank)) * weights.keywordWeight; } if (hasSemanticMatch) { // Apply adaptive weight to semantic score rrf += (1 / (k + semRank)) * weights.semanticWeight; } return { note, rrf }; }); return rrfScores .filter(item => item.rrf > 0) .sort((a, b) => b.rrf - a.rrf) .map(item => item.note); } catch (error) { console.error('Error searching notes:', error) return [] } } // 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' sharedWith?: string[] }) { const session = await auth(); if (!session?.user?.id) throw new Error('Unauthorized'); try { let embeddingString: string | null = null; try { const provider = getAIProvider(await getSystemConfig()); const embedding = await provider.getEmbeddings(data.content); if (embedding) embeddingString = JSON.stringify(embedding); } catch (e) { console.error('Embedding generation failed:', e); } 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 ? 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: embeddingString, sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null, } }) // Sync labels to ensure Label records exist if (data.labels && data.labels.length > 0) { await syncLabels(session.user.id, data.labels) } revalidatePath('/') 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' }) { 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 } }) const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : [] const updateData: any = { ...data } if (data.content !== undefined) { try { const provider = getAIProvider(await getSystemConfig()); const embedding = await provider.getEmbeddings(data.content); updateData.embedding = embedding ? JSON.stringify(embedding) : null; } catch (e) { console.error('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 updateData.updatedAt = new Date() const note = await prisma.note.update({ where: { id, userId: session.user.id }, data: updateData }) // Sync labels to ensure consistency between Note.labels and Label table // This handles both creating new Label records and cleaning up orphans if (data.labels !== undefined) { await syncLabels(session.user.id, data.labels || []) } // Don't revalidatePath here - it would close the note editor dialog! // The dialog will close via the onClose callback after save completes // The UI will update via the normal React state management 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 }) } // 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') } } // Maintenance - Sync all labels and clean up orphans 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 { // Step 1: Get all labels from notes const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } }) const allNoteLabels = new Set(); allNotes.forEach(note => { if (note.labels) { try { const parsedLabels: string[] = JSON.parse(note.labels); if (Array.isArray(parsedLabels)) { parsedLabels.forEach(l => { if (l && l.trim()) allNoteLabels.add(l.trim()); }); } } catch (e) { console.error('[CLEANUP] Failed to parse labels:', e); } } }); console.log(`[CLEANUP] Found ${allNoteLabels.size} unique labels in notes`, Array.from(allNoteLabels)); // Step 2: Get existing labels for case-insensitive comparison const existingLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } }) const existingLabelMap = new Map() existingLabels.forEach(label => { existingLabelMap.set(label.name.toLowerCase(), label.name) }) console.log(`[CLEANUP] Found ${existingLabels.length} existing labels in database`); // Step 3: Create missing Label records for (const labelName of allNoteLabels) { const lowerLabel = labelName.toLowerCase(); const existingName = existingLabelMap.get(lowerLabel); if (!existingName) { try { await prisma.label.create({ data: { userId, name: labelName, color: getHashColor(labelName) } }); createdCount++; existingLabelMap.set(lowerLabel, labelName); console.log(`[CLEANUP] Created label: "${labelName}"`); } catch (e: any) { console.error(`[CLEANUP] Failed to create label "${labelName}":`, e); errors.push({ label: labelName, error: e.message, code: e.code }); // Continue with next label } } } console.log(`[CLEANUP] Created ${createdCount} new labels`); // Step 4: Delete orphan Label records const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } }) const usedLabelsSet = new Set(); allNotes.forEach(note => { if (note.labels) { try { const parsedLabels: string[] = JSON.parse(note.labels); if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase())); } catch (e) { console.error('[CLEANUP] Failed to parse labels for orphan check:', e); } } }); const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase())); for (const orphan of orphans) { try { await prisma.label.delete({ where: { id: orphan.id } }); deletedCount++; console.log(`[CLEANUP] Deleted orphan label: "${orphan.name}"`); } catch (e) { console.error(`[CLEANUP] Failed to delete orphan "${orphan.name}":`, e); } } console.log(`[CLEANUP] Deleted ${deletedCount} orphan labels`); revalidatePath('/') 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 { console.log('[DEBUG] getAllNotes for user:', userId, 'includeArchived:', includeArchived) // Get user's own notes const ownNotes = await prisma.note.findMany({ where: { userId: userId, ...(includeArchived ? {} : { isArchived: false }), }, orderBy: [ { isPinned: 'desc' }, { order: 'asc' }, { updatedAt: 'desc' } ] }) console.log('[DEBUG] Found', ownNotes.length, 'own notes') // Get notes shared with user via NoteShare (accepted only) const acceptedShares = await prisma.noteShare.findMany({ where: { userId: userId, status: 'accepted' }, include: { note: true } }) console.log('[DEBUG] Found', acceptedShares.length, 'accepted shares') // Filter out archived shared notes if needed const sharedNotes = acceptedShares .map(share => share.note) .filter(note => includeArchived || !note.isArchived) console.log('[DEBUG] After filtering archived:', sharedNotes.length, 'shared notes') const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)] console.log('[DEBUG] Returning total:', allNotes.length, 'notes') return allNotes } catch (error) { console.error('Error fetching notes:', error) return [] } } // 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 { console.log('[DEBUG] prisma.noteShare:', typeof prisma.noteShare) 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 { console.log('[DEBUG] respondToShareRequest:', shareId, action, 'for user:', session.user.id) const share = await prisma.noteShare.findUnique({ where: { id: shareId }, include: { note: true, sharer: true } }); if (!share) { throw new Error('Share request not found'); } console.log('[DEBUG] Share found:', share) // 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 } } } }); console.log('[DEBUG] Share updated:', updatedShare.status) // Revalidate all relevant cache tags revalidatePath('/'); console.log('[DEBUG] Cache revalidated, returning success') 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; } }