sepehr 640fcb26f7 fix: improve note interactions and markdown LaTeX support
## Bug Fixes

### Note Card Actions
- Fix broken size change functionality (missing state declaration)
- Implement React 19 useOptimistic for instant UI feedback
- Add startTransition for non-blocking updates
- Ensure smooth animations without page refresh
- All note actions now work: pin, archive, color, size, checklist

### Markdown LaTeX Rendering
- Add remark-math and rehype-katex plugins
- Support inline equations with dollar sign syntax
- Support block equations with double dollar sign syntax
- Import KaTeX CSS for proper styling
- Equations now render correctly instead of showing raw LaTeX

## Technical Details

- Replace undefined currentNote references with optimistic state
- Add optimistic updates before server actions for instant feedback
- Use router.refresh() in transitions for smart cache invalidation
- Install remark-math, rehype-katex, and katex packages

## Testing

- Build passes successfully with no TypeScript errors
- Dev server hot-reloads changes correctly
2026-01-09 22:13:49 +01:00

1212 lines
36 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 { 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<string, string>()
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<string>()
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<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 },
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<string>();
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<string, string>()
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<string>();
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;
}
}