Files
Keep/keep-notes/app/actions/notes.ts
Sepehr Ramezani 39671c6472 fix(keep-notes): sidebar chevron, labels sync, batch org errors, perf guards
- Notebooks: chevron visible when expanded (remove overflow clip), functional expand state
- Labels: sync/cleanup by notebookId, reconcile after note move
- Settings: refresh notebooks after cleanup; label dialog routing
- ConnectionsBadge lazy-load; reminder check persistence; i18n keys

Made-with: Cursor
2026-04-13 22:07:09 +02:00

1493 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 dun 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<string>()
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'
sharedWith?: string[]
autoGenerated?: boolean
notebookId?: string | undefined // Assign note to a notebook if provided
}) {
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
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
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)
}
// 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)
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
}) {
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)
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
const isContentChange = contentFields.some(field => field in data)
if (isContentChange) {
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) {
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<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')
}
}
// 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<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (key) usedSet.add(key)
}
}
let allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
const ensuredPairs = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (!key || ensuredPairs.has(key)) continue
ensuredPairs.add(key)
const trimmed = name.trim()
const nb = note.notebookId ?? null
const exists = allScoped.some(
l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase()
)
if (exists) continue
try {
const created = await prisma.label.create({
data: {
userId,
name: trimmed,
color: getHashColor(trimmed),
notebookId: nb,
},
})
allScoped.push(created)
createdCount++
} catch (e: any) {
console.error(`Failed to create label:`, e)
errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
}
}
}
allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
for (const label of allScoped) {
const key = labelScopeKey(label.notebookId, label.name)
if (!key || usedSet.has(key)) continue
try {
await prisma.label.update({
where: { id: label.id },
data: { notes: { set: [] } },
})
await prisma.label.delete({ where: { id: label.id } })
deletedCount++
} catch (e: any) {
console.error(`Failed to delete orphan ${label.id}:`, e)
errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
}
}
revalidatePath('/')
revalidatePath('/settings')
return {
success: true,
created: createdCount,
deleted: deletedCount,
errors,
message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}`
}
} catch (error) {
console.error('[CLEANUP] Fatal error:', error);
throw new Error('Failed to cleanup database')
}
}
export async function syncAllEmbeddings() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
let updatedCount = 0;
try {
const notesToSync = await prisma.note.findMany({ where: { userId, 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;
}
}