Files
Momento/memento-note/app/actions/notes.ts
Antigravity 8c7ca69640
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s
fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
2026-05-16 18:50:34 +00:00

2028 lines
59 KiB
TypeScript

'use server'
import { revalidatePath } from 'next/cache'
import prisma from '@/lib/prisma'
import { Note, CheckItem, NoteType } from '@/lib/types'
import { auth } from '@/auth'
import { getAIProvider } from '@/lib/ai/factory'
import { parseNote as parseNoteUtil } from '@/lib/utils'
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
import { getAISettings } from '@/app/actions/ai-settings'
import {
createNoteHistorySnapshot,
getNoteHistoryMode,
isNoteHistoryEnabledForUser,
parseNoteHistoryEntry,
shouldCaptureHistorySnapshot,
shouldCreateAutoSnapshot,
} from '@/lib/note-history'
/**
* Champs sélectionnés pour les listes de notes (sans embedding pour économiser ~6KB/note).
* L'embedding ne charge que pour la recherche sémantique.
*/
const NOTE_LIST_SELECT = {
id: true,
title: true,
content: true,
color: true,
isPinned: true,
isArchived: true,
trashedAt: true,
type: true,
dismissedFromRecent: true,
checkItems: true,
labels: true,
images: true,
illustrationSvg: true,
links: true,
reminder: true,
isReminderDone: true,
reminderRecurrence: true,
reminderLocation: true,
isMarkdown: true,
size: true,
sharedWith: true,
userId: true,
order: true,
notebookId: true,
createdAt: true,
updatedAt: true,
contentUpdatedAt: true,
autoGenerated: true,
aiProvider: true,
aiConfidence: true,
language: true,
languageConfidence: true,
lastAiAnalysis: true,
historyEnabled: true,
} as const
// Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table)
function parseNote(dbNote: any): Note {
return parseNoteUtil(dbNote)
}
async function ensureSessionUserExists(sessionUser: { id: string; email?: string | null; name?: string | null }) {
const fallbackEmail = `user-${sessionUser.id}@local.momento`
const safeEmail = sessionUser.email || fallbackEmail
await prisma.user.upsert({
where: { id: sessionUser.id },
update: {
...(sessionUser.email ? { email: sessionUser.email } : {}),
...(sessionUser.name !== undefined ? { name: sessionUser.name } : {}),
},
create: {
id: sessionUser.id,
email: safeEmail,
name: sessionUser.name || null,
},
})
}
// 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
}
/**
* Upsert Label rows and return their IDs.
* No orphan cleanup — labels are only deleted via the label management dialog.
*/
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> {
try {
const nbScope = notebookId ?? null
if (noteLabels.length > 0) {
// Deduplicate case-insensitively, keep original case
const seen = new Set<string>()
const trimmedNames = noteLabels
.map(name => name?.trim())
.filter((n): n is string => Boolean(n))
.filter(n => {
const key = n.toLowerCase()
if (seen.has(key)) return false
seen.add(key)
return true
})
for (const name of trimmedNames) {
// Case-insensitive find on PostgreSQL
const existing = await prisma.label.findFirst({
where: { userId, name: { equals: name, mode: 'insensitive' }, notebookId: nbScope },
})
if (!existing) {
await prisma.label.create({
data: { userId, name, color: getHashColor(name), notebookId: nbScope },
})
}
}
if (trimmedNames.length === 0) return []
// Search with original case (case-insensitive on PostgreSQL)
return prisma.label.findMany({
where: { userId, notebookId: nbScope, name: { in: trimmedNames, mode: 'insensitive' } },
select: { id: true, name: true },
})
}
return []
} catch (error) {
console.error('Fatal error in syncLabels:', error)
return []
}
}
/** Sync both Note.labels (JSON) AND labelRelations for a single note.
* Also cleans up orphan labels in the same notebook scope. */
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
const labelIds = labelRows.map(l => l.id)
await prisma.note.update({
where: { id: noteId },
data: {
labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null,
labelRelations: { set: labelIds.map(id => ({ id })) },
},
})
// Clean up orphan labels: labels in this notebook scope that are no longer
// referenced by any note (neither via JSON nor via labelRelations)
if (notebookId !== null) {
const allLabels = await prisma.label.findMany({
where: { notebookId, userId },
select: { id: true, name: true },
})
if (allLabels.length > 0) {
const labelIdsSet = new Set(allLabels.map(l => l.id))
// Find notes in this notebook that have any label relation
const notesWithLabels = await prisma.note.findMany({
where: {
notebookId,
userId,
labelRelations: { some: { id: { in: Array.from(labelIdsSet) } } },
},
select: { labels: true },
})
// Collect all label names still in use via JSON
const namesInUse = new Set<string>()
for (const n of notesWithLabels) {
if (n.labels) {
try {
const parsed = JSON.parse(n.labels as string)
if (Array.isArray(parsed)) {
parsed.filter((x: any) => typeof x === 'string').forEach((x: string) => namesInUse.add(x.toLowerCase()))
}
} catch { }
}
}
// Delete labels not in use
const orphans = allLabels.filter(l => !namesInUse.has(l.name.toLowerCase()))
if (orphans.length > 0) {
await prisma.label.deleteMany({
where: { id: { in: orphans.map(l => l.id) } },
})
}
}
}
}
/** 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 syncNoteLabels(noteId, labels, newNotebookId, session.user.id)
}
// Get all notes (non-archived by default)
export async function getNotes(includeArchived = false) {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
},
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching notes:', error)
return []
}
}
// Get notes with reminders (upcoming, overdue, done)
export async function getNotesWithReminders() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: null,
isArchived: false,
reminder: { not: null }
},
select: NOTE_LIST_SELECT,
orderBy: { reminder: 'asc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching notes with reminders:', error)
return []
}
}
// Mark a reminder as done / undone
export async function toggleReminderDone(noteId: string, done: boolean) {
const session = await auth();
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.note.update({
where: { id: noteId, userId: session.user.id },
data: { isReminderDone: done }
})
revalidatePath('/reminders')
return { success: true }
} catch (error) {
console.error('Error toggling reminder done:', error)
return { error: 'Failed to update reminder' }
}
}
// Clear completed reminders (set reminder to null for done reminders)
export async function clearCompletedReminders() {
const session = await auth();
if (!session?.user?.id) return { error: 'Unauthorized' }
try {
await prisma.note.updateMany({
where: {
userId: session.user.id,
isReminderDone: true,
reminder: { not: null },
},
data: {
reminder: null,
isReminderDone: false,
},
})
revalidatePath('/reminders')
return { success: true }
} catch (error) {
console.error('Error clearing completed reminders:', error)
return { error: 'Failed to clear reminders' }
}
}
// Get archived notes only
export async function getArchivedNotes() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
isArchived: true,
trashedAt: null
},
select: NOTE_LIST_SELECT,
orderBy: { updatedAt: 'desc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching archived notes:', error)
return []
}
}
export async function getNoteHistory(noteId: string, limit = 30) {
const session = await auth()
if (!session?.user?.id) return []
const clampedLimit = Math.min(Math.max(limit, 1), 100)
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, historyEnabled: true },
})
// History not found or not enabled on this note
if (!note || !note.historyEnabled) return []
const entries = await prisma.noteHistory.findMany({
where: { noteId: note.id, userId: session.user.id },
orderBy: { createdAt: 'desc' },
take: clampedLimit,
})
return entries.map(parseNoteHistoryEntry)
}
export async function restoreNoteVersion(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const [note, historyEntry] = await Promise.all([
prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, notebookId: true, historyEnabled: true },
}),
prisma.noteHistory.findFirst({
where: {
id: historyEntryId,
noteId,
userId: session.user.id,
},
}),
])
if (!note || !note.historyEnabled) throw new Error('History is disabled for this note')
if (!historyEntry) throw new Error('History entry not found')
const userId = session.user.id
const restored = await prisma.note.update({
where: { id: note.id, userId },
data: {
title: historyEntry.title,
content: historyEntry.content,
color: historyEntry.color,
isPinned: historyEntry.isPinned,
isArchived: historyEntry.isArchived,
type: historyEntry.type,
checkItems: historyEntry.checkItems,
labels: historyEntry.labels,
images: historyEntry.images,
links: historyEntry.links,
isMarkdown: historyEntry.isMarkdown,
size: historyEntry.size,
notebookId: historyEntry.notebookId,
contentUpdatedAt: new Date(),
},
})
revalidatePath('/home')
return parseNote(restored)
}
export async function commitNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true, historyEnabled: true },
})
if (!note) throw new Error('Note not found')
if (!note.historyEnabled) throw new Error('History is disabled for this note')
await createNoteHistorySnapshot({
noteId: note.id,
userId: session.user.id,
reason: 'manual-commit',
})
}
export async function deleteNoteHistoryEntry(noteId: string, historyEntryId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const entry = await prisma.noteHistory.findFirst({
where: { id: historyEntryId, noteId, userId: session.user.id },
})
if (!entry) throw new Error('History entry not found')
await prisma.noteHistory.delete({
where: { id: historyEntryId },
})
}
export async function enableNoteHistory(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id },
select: { id: true },
})
if (!note) throw new Error('Note not found')
await prisma.note.update({
where: { id: noteId },
data: { historyEnabled: true },
})
}
// Unified hybrid search — always uses FTS + pgvector with RRF fusion.
// Supports contextual search within notebook (IA5).
export async function searchNotes(query: string, _useSemantic: boolean = true, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
try {
if (!query || !query.trim()) {
return await getAllNotes();
}
const results = await semanticSearchService.searchAsUser(session.user.id, query, {
limit: 50,
threshold: 0.25,
notebookId
});
const noteIds = results.map(r => r.noteId);
const notes = await prisma.note.findMany({
where: {
id: { in: noteIds },
userId: session.user.id,
isArchived: false,
trashedAt: null,
},
select: NOTE_LIST_SELECT,
});
const orderMap = new Map(results.map((r, i) => [r.noteId, i]));
const parsed = notes.map(parseNote);
parsed.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
if (parsed.length > 0) {
const topResult = results[0];
if (topResult) {
parsed[0].matchType = topResult.matchType;
parsed[0].searchScore = topResult.score;
}
}
return parsed;
} catch (error) {
console.error('Search error:', error);
return [];
}
}
// Create a new note
export async function createNote(data: {
title?: string
content: string
color?: string
type?: NoteType
checkItems?: CheckItem[]
labels?: string[]
images?: string[]
links?: any[]
isArchived?: boolean
reminder?: Date | null
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean
aiProvider?: string
notebookId?: string | undefined
skipRevalidation?: boolean
}) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Defensive guard: after DB reset/migration, auth session can exist while User row is missing.
// Recreate user row to avoid Note_userId_fkey failures.
await ensureSessionUserExists({
id: session.user.id,
email: session.user.email,
name: session.user.name,
})
// 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 || 'richtext',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: null, // set by syncNoteLabels below
images: data.images ? JSON.stringify(data.images) : null,
links: data.links ? JSON.stringify(data.links) : null,
isArchived: data.isArchived || false,
reminder: data.reminder || null,
isMarkdown: data.isMarkdown || false,
size: data.size || 'small',
autoGenerated: data.autoGenerated || null,
aiProvider: data.aiProvider || null,
notebookId: data.notebookId || null,
}
})
// Sync labels (JSON + labelRelations + Label rows) in one call
if (data.labels && data.labels.length > 0) {
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
}
try {
// New notes start with historyEnabled=false (schema default),
// so no initial snapshot is needed here.
// History is enabled per-note via enableNoteHistory() action.
} catch (snapshotError) {
console.error('[HISTORY] Failed to create initial snapshot:', snapshotError)
}
if (!data.skipRevalidation) {
// Revalidate main page (handles both inbox and notebook views via query params)
revalidatePath('/home')
}
// 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 {
const bgConfig = await getSystemConfig()
const provider = getAIProvider(bgConfig)
const embedding = await provider.getEmbeddings(content)
if (embedding) {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
noteId,
vecStr
)
}
} catch (e) {
console.error('[BG] Embedding generation failed:', e)
}
// Background task 2: Auto-labeling (only if no user labels and has notebook)
if (!hasUserLabels && notebookId) {
try {
const userAISettings = await getAISettings(userId)
const autoLabelingEnabled = userAISettings.autoLabeling !== false
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
console.log('[BG] Auto-labeling check: enabled=', autoLabelingEnabled, 'confidence=', autoLabelingConfidence, 'notebookId=', notebookId)
if (autoLabelingEnabled) {
// Detect user's language from their existing notes for localized prompts
let userLang = 'en'
try {
const langResult = await prisma.note.groupBy({
by: ['language'],
where: { userId, language: { not: null } },
_count: true,
orderBy: { _count: { language: 'desc' } },
take: 1,
})
if (langResult.length > 0 && langResult[0].language) {
userLang = langResult[0].language
}
} catch { }
const suggestions = await contextualAutoTagService.suggestLabels(
content,
notebookId,
userId,
userLang
)
console.log('[BG] Auto-labeling suggestions:', suggestions.length, suggestions.map(s => s.label))
const appliedLabels = suggestions
.filter(s => s.confidence >= autoLabelingConfidence)
.map(s => s.label)
if (appliedLabels.length > 0) {
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
select: { labels: true },
})
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/home')
}
}
}
} catch (error) {
console.error('[BG] Auto-labeling failed:', error)
}
} else {
console.log('[BG] Auto-labeling skipped: hasUserLabels=', hasUserLabels, 'notebookId=', notebookId)
}
})()
return parseNote(note)
} catch (error) {
console.error('Error creating note:', error)
throw new Error('Failed to create note')
}
}
// Update a note
export async function updateNote(id: string, data: {
title?: string | null
content?: string
color?: string
isPinned?: boolean
isArchived?: boolean
type?: NoteType
checkItems?: CheckItem[] | null
labels?: string[] | null
images?: string[] | null
illustrationSvg?: string | null
links?: any[] | null
reminder?: Date | null
isMarkdown?: boolean
size?: 'small' | 'medium' | 'large'
autoGenerated?: boolean | null
aiProvider?: string | null
notebookId?: string | null
}, options?: { skipContentTimestamp?: boolean; skipRevalidation?: boolean }) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const oldNote = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { labels: true, notebookId: true, reminder: true, content: true, title: true, historyEnabled: 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
}
}
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) {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
noteId,
vecStr
)
}
} catch (e) {
console.error('[BG] Embedding regeneration failed:', e);
}
})()
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
// labels handled by syncNoteLabels below
delete updateData.labels
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
if ('illustrationSvg' in data) updateData.illustrationSvg = data.illustrationSvg
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
if ('notebookId' in data) updateData.notebookId = data.notebookId
// Explicitly handle size to ensure it propagates
if ('size' in data && data.size) updateData.size = data.size
// Only update contentUpdatedAt for actual content changes, NOT for property changes
// (size, color, isPinned, isArchived are properties, not content)
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
const contentFields = ['title', 'content', 'checkItems', 'images', 'links', 'illustrationSvg']
const isContentChange = contentFields.some(field => field in data)
if (isContentChange && !options?.skipContentTimestamp) {
updateData.contentUpdatedAt = new Date()
}
console.log('[updateNote] Attempting update, id:', id, 'userId:', session.user.id)
let note
try {
note = await prisma.note.update({
where: { id, userId: session.user.id },
data: updateData
})
console.log('[updateNote] Succeeded, note id:', note?.id)
} catch (dbError: any) {
console.error('[updateNote] FAILED:', dbError.code, dbError.message)
throw dbError
}
// Sync labels (JSON + labelRelations + Label rows)
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 syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
}
try {
const historyEnabled = oldNote?.historyEnabled === true
if (historyEnabled && shouldCaptureHistorySnapshot(data as Record<string, unknown>)) {
const mode = await getNoteHistoryMode(session.user.id)
if (mode === 'manual') {
// No auto-snapshot in manual mode — user commits explicitly
} else {
const shouldAuto = await shouldCreateAutoSnapshot({
noteId: id,
userId: session.user.id,
updateData: data as Record<string, unknown>,
existingContent: oldNote?.content ?? '',
existingTitle: oldNote?.title ?? null,
})
if (shouldAuto) {
await createNoteHistorySnapshot({
noteId: id,
userId: session.user.id,
reason: 'update',
})
}
}
}
} catch (snapshotError) {
console.error('[HISTORY] Failed to create snapshot after update:', snapshotError)
}
// 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)
console.log('[updateNote] Structural check — data fields:', Object.keys(data), '| isStructural:', isStructuralChange)
if (!options?.skipRevalidation) {
try { revalidatePath(`/note/${id}`) } catch {}
try { revalidatePath('/home') } catch {}
}
if (isStructuralChange) {
if (data.isArchived !== undefined) {
revalidatePath('/archive')
}
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
if (oldNotebookId) {
revalidatePath(`/notebook/${oldNotebookId}`)
}
if (data.notebookId) {
revalidatePath(`/notebook/${data.notebookId}`)
}
}
}
if (data.labels !== undefined) {
const refreshed = await prisma.note.findUnique({ where: { id } })
if (refreshed) return parseNote(refreshed)
}
return parseNote(note)
} catch (error) {
console.error('Error updating note:', error)
throw error // Re-throw the REAL error, not a generic one
}
}
// Soft-delete a note (move to trash)
export async function deleteNote(id: string, options?: { skipRevalidation?: boolean }) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true }
} catch (error) {
console.error('Error deleting note:', error)
throw new Error('Failed to delete note')
}
}
// Trash actions
export async function trashNote(id: string, options?: { skipRevalidation?: boolean }) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: new Date() }
})
if (!options?.skipRevalidation) {
revalidatePath('/home')
}
return { success: true }
} catch (error) {
console.error('Error trashing note:', error)
throw new Error('Failed to trash note')
}
}
export async function restoreNote(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { trashedAt: null }
})
revalidatePath('/home')
revalidatePath('/trash')
return { success: true }
} catch (error) {
console.error('Error restoring note:', error)
throw new Error('Failed to restore note')
}
}
export async function getTrashedNotes() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
select: NOTE_LIST_SELECT,
orderBy: { trashedAt: 'desc' }
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching trashed notes:', error)
return []
}
}
export async function permanentDeleteNote(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Fetch images before deleting so we can clean up files
const note = await prisma.note.findUnique({
where: { id, userId: session.user.id },
select: { images: true }
})
const imageUrls = parseImageUrls(note?.images ?? null)
await prisma.note.delete({ where: { id, userId: session.user.id } })
// Clean up orphaned image files (safe: skips if referenced by other notes)
if (imageUrls.length > 0) {
await cleanupNoteImages(id, imageUrls)
}
await syncLabels(session.user.id, [])
revalidatePath('/trash')
revalidatePath('/home')
return { success: true }
} catch (error) {
console.error('Error permanently deleting note:', error)
throw new Error('Failed to permanently delete note')
}
}
export async function emptyTrash() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const trashedNotes = await prisma.note.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
select: { id: true, images: true }
})
await prisma.note.deleteMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
})
for (const note of trashedNotes) {
const imageUrls = parseImageUrls(note.images)
if (imageUrls.length > 0) {
await cleanupNoteImages(note.id, imageUrls)
}
}
await prisma.notebook.deleteMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
})
await syncLabels(session.user.id, [])
revalidatePath('/trash')
revalidatePath('/home')
return { success: true }
} catch (error) {
console.error('Error emptying trash:', error)
throw new Error('Failed to empty trash')
}
}
export async function removeImageFromNote(noteId: string, imageIndex: number) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
try {
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id },
select: { images: true },
})
if (!note) throw new Error('Note not found')
const imageUrls = parseImageUrls(note.images)
if (imageIndex < 0 || imageIndex >= imageUrls.length) throw new Error('Invalid image index')
const removedUrl = imageUrls[imageIndex]
const newImages = imageUrls.filter((_, i) => i !== imageIndex)
await prisma.note.update({
where: { id: noteId },
data: { images: newImages.length > 0 ? JSON.stringify(newImages) : null },
})
// Clean up file if no other note references it
await deleteImageFileSafely(removedUrl, noteId)
return { success: true }
} catch (error) {
console.error('Error removing image:', error)
throw new Error('Failed to remove image')
}
}
export async function cleanupOrphanedImages(imageUrls: string[], noteId: string) {
const session = await auth()
if (!session?.user?.id) return
try {
for (const url of imageUrls) {
await deleteImageFileSafely(url, noteId)
}
} catch {
// Silent — best-effort cleanup
}
}
export async function getTrashCount() {
const session = await auth();
if (!session?.user?.id) return 0;
try {
const [noteCount, notebookCount] = await Promise.all([
prisma.note.count({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
}),
prisma.notebook.count({
where: {
userId: session.user.id,
trashedAt: { not: null }
}
})
])
return noteCount + notebookCount
} catch {
return 0
}
}
export async function getTrashedNotebooks() {
const session = await auth();
if (!session?.user?.id) return [];
try {
return await prisma.notebook.findMany({
where: {
userId: session.user.id,
trashedAt: { not: null }
},
include: {
_count: { select: { notes: true } }
},
orderBy: { trashedAt: 'desc' }
})
} catch (error) {
console.error('Error fetching trashed notebooks:', error)
return []
}
}
// Toggle functions
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null, aiProvider: null }) }
// Update note size WITHOUT revalidation - client uses optimistic updates
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
const result = await updateNote(id, { size })
return result
}
// Get all unique labels
export async function getAllLabels() {
const session = await auth();
if (!session?.user?.id) return [];
try {
const notes = await prisma.note.findMany({
where: { userId: session.user.id },
select: { labels: true }
})
const labelsSet = new Set<string>()
notes.forEach((note: any) => {
const labels = note.labels ? JSON.parse(note.labels) : null
if (labels) labels.forEach((label: string) => labelsSet.add(label))
})
return Array.from(labelsSet).sort()
} catch (error) {
console.error('Error fetching labels:', error)
return []
}
}
// Reorder
export async function reorderNotes(draggedId: string, targetId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } })
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
if (!draggedNote || !targetNote) throw new Error('Notes not found')
const allNotes = await prisma.note.findMany({
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false, trashedAt: null },
orderBy: { order: 'asc' }
})
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId)
reorderedNotes.splice(targetIndex, 0, draggedNote)
const updates = reorderedNotes.map((note: any, index: number) =>
prisma.note.update({ where: { id: note.id }, data: { order: index } })
)
await prisma.$transaction(updates)
revalidatePath('/home')
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('/home')
return { success: true }
} catch (error) {
throw new Error('Failed to update order')
}
}
// Optimized version for drag & drop - no revalidation to prevent double refresh
export async function updateFullOrderWithoutRevalidation(ids: string[]) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
try {
// Verify all notes belong to the user before updating
const notes = await prisma.note.findMany({
where: { id: { in: ids }, userId },
select: { id: true },
})
const ownedIds = new Set(notes.map(n => n.id))
const validIds = ids.filter(id => ownedIds.has(id))
const updates = validIds.map((id: string, index: number) =>
prisma.note.update({ where: { id }, data: { order: index } })
)
await prisma.$transaction(updates)
return { success: true }
} catch (error) {
throw new Error('Failed to update order')
}
}
// Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels)
export async function cleanupAllOrphans() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
let createdCount = 0;
let deletedCount = 0;
let errors: any[] = [];
try {
const allNotes = await prisma.note.findMany({
where: { userId },
select: {
notebookId: true,
labels: true,
labelRelations: { select: { name: true } },
},
})
const usedSet = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (key) usedSet.add(key)
}
}
let allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
const ensuredPairs = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (!key || ensuredPairs.has(key)) continue
ensuredPairs.add(key)
const trimmed = name.trim()
const nb = note.notebookId ?? null
const exists = allScoped.some(
l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase()
)
if (exists) continue
try {
const created = await prisma.label.create({
data: {
userId,
name: trimmed,
color: getHashColor(trimmed),
notebookId: nb,
},
})
allScoped.push(created)
createdCount++
} catch (e: any) {
console.error(`Failed to create label:`, e)
errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
}
}
}
allScoped = await prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
for (const label of allScoped) {
const key = labelScopeKey(label.notebookId, label.name)
if (!key || usedSet.has(key)) continue
try {
await prisma.label.update({
where: { id: label.id },
data: { notes: { set: [] } },
})
await prisma.label.delete({ where: { id: label.id } })
deletedCount++
} catch (e: any) {
console.error(`Failed to delete orphan ${label.id}:`, e)
errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
}
}
revalidatePath('/home')
revalidatePath('/settings')
return {
success: true,
created: createdCount,
deleted: deletedCount,
errors,
message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}`
}
} catch (error) {
console.error('[CLEANUP] Fatal error:', error);
throw new Error('Failed to cleanup database')
}
}
export async function syncAllEmbeddings() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
const userId = session.user.id;
let updatedCount = 0;
try {
const notesToSync = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
noteEmbedding: { is: null }
}
})
const provider = getAIProvider(await getSystemConfig());
for (const note of notesToSync) {
if (!note.content) continue;
try {
const embedding = await provider.getEmbeddings(note.content);
if (embedding) {
const vecStr = `[${embedding.join(',')}]`
await prisma.$executeRawUnsafe(
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
ON CONFLICT ("noteId")
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
note.id,
vecStr
)
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, notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
const whereClause: any = {
userId,
trashedAt: null,
...(includeArchived ? {} : { isArchived: false }),
...(notebookId !== undefined ? { notebookId: notebookId || null } : {}),
}
const ownNotes = await prisma.note.findMany({
where: whereClause,
select: NOTE_LIST_SELECT,
orderBy: [
{ isPinned: 'desc' },
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
if (notebookId) {
return ownNotes.map(parseNote)
}
const [acceptedShares] = await Promise.all([
prisma.noteShare.findMany({
where: { userId, status: 'accepted' },
include: { note: { select: NOTE_LIST_SELECT } }
})
])
const sharedNotes = acceptedShares
.map(share => share.note)
.filter(note => includeArchived || !note.isArchived)
.map(note => ({ ...note, _isShared: true }))
return [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
} catch (error) {
console.error('Error fetching notes:', error)
return []
}
}
// Get pinned notes only
export async function getPinnedNotes(notebookId?: string) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
const notes = await prisma.note.findMany({
where: {
userId: userId,
isPinned: true,
isArchived: false,
trashedAt: null,
...(notebookId !== undefined ? { notebookId } : {})
},
orderBy: [
{ order: 'asc' },
{ updatedAt: 'desc' }
]
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching pinned notes:', error)
return []
}
}
// Get recent notes (notes modified in the last 7 days)
// Get recent notes (notes modified in the last 7 days)
export async function getRecentNotes(limit: number = 3) {
const session = await auth();
if (!session?.user?.id) return [];
const userId = session.user.id;
try {
const sevenDaysAgo = new Date()
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day
const notes = await prisma.note.findMany({
where: {
userId: userId,
contentUpdatedAt: { gte: sevenDaysAgo },
isArchived: false,
trashedAt: null,
dismissedFromRecent: false // Filter out dismissed notes
},
orderBy: { contentUpdatedAt: 'desc' },
take: limit
})
return notes.map(parseNote)
} catch (error) {
console.error('Error fetching recent notes:', error)
return []
}
}
// Dismiss a note from Recent section
export async function dismissFromRecent(id: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
await prisma.note.update({
where: { id, userId: session.user.id },
data: { dismissedFromRecent: true }
})
// revalidatePath('/home') // Removed to prevent immediate refill of the list
return { success: true }
} catch (error) {
console.error('Error dismissing note from recent:', error)
throw new Error('Failed to dismiss note')
}
}
export async function getNoteById(noteId: string) {
const session = await auth();
if (!session?.user?.id) return null;
const userId = session.user.id;
try {
const note = await prisma.note.findFirst({
where: {
id: noteId,
OR: [
{ userId: userId },
{
shares: {
some: {
userId: userId,
status: 'accepted'
}
}
}
]
}
})
if (!note) return null
return parseNote(note)
} catch (error) {
console.error('Error fetching note:', error)
return null
}
}
// Add a collaborator to a note (updated to use new share request system)
export async function addCollaborator(noteId: string, userEmail: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Use the new share request system
const result = await createShareRequest(noteId, userEmail, 'view');
// Get user info for response
const targetUser = await prisma.user.findUnique({
where: { email: userEmail },
select: { id: true, name: true, email: true, image: true }
});
if (!targetUser) {
throw new Error('User not found')
}
return { success: true, user: targetUser }
} catch (error: any) {
console.error('Error adding collaborator:', error)
throw error
}
}
// Remove a collaborator from a note
export async function removeCollaborator(noteId: string, userId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const note = await prisma.note.findUnique({
where: { id: noteId }
})
if (!note) {
throw new Error('Note not found')
}
if (note.userId !== session.user.id) {
throw new Error('You can only manage collaborators on your own notes')
}
// Delete the NoteShare record (cascades to remove access)
await prisma.noteShare.deleteMany({
where: {
noteId,
userId
}
})
// Don't revalidatePath here - it would close all open dialogs!
// The UI will update via optimistic updates in the dialog
return { success: true }
} catch (error: any) {
console.error('Error removing collaborator:', error)
throw new Error(error.message || 'Failed to remove collaborator')
}
}
// Get collaborators for a note
export async function getNoteCollaborators(noteId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { userId: true }
})
if (!note) {
throw new Error('Note not found')
}
// Owner can always see collaborators
// Shared users can also see collaborators if they have accepted access
if (note.userId !== session.user.id) {
const share = await prisma.noteShare.findUnique({
where: {
noteId_userId: {
noteId,
userId: session.user.id
}
}
})
if (!share || share.status !== 'accepted') {
throw new Error('You do not have access to this note')
}
}
// Get all users who have been shared this note (any status)
const shares = await prisma.noteShare.findMany({
where: {
noteId
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
}
})
return shares.map(share => share.user)
} catch (error: any) {
console.error('Error fetching collaborators:', error)
throw new Error(error.message || 'Failed to fetch collaborators')
}
}
// Get all users associated with a note (owner + collaborators)
export async function getNoteAllUsers(noteId: string) {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const note = await prisma.note.findUnique({
where: { id: noteId },
select: { userId: true }
})
if (!note) {
throw new Error('Note not found')
}
// Check access via NoteShare
const share = await prisma.noteShare.findUnique({
where: {
noteId_userId: {
noteId,
userId: session.user.id
}
}
})
const hasAccess = note.userId === session.user.id || (share && share.status === 'accepted')
if (!hasAccess) {
throw new Error('You do not have access to this note')
}
// Get owner
const owner = await prisma.user.findUnique({
where: { id: note.userId! },
select: {
id: true,
name: true,
email: true,
image: true
}
})
if (!owner) {
throw new Error('Owner not found')
}
// Get collaborators (accepted shares)
const shares = await prisma.noteShare.findMany({
where: {
noteId,
status: 'accepted'
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
}
})
const collaborators = shares.map(share => share.user)
// Return owner + collaborators
return [owner, ...collaborators]
} catch (error: any) {
console.error('Error fetching note users:', error)
throw new Error(error.message || 'Failed to fetch note users')
}
}
// ============================================================
// NEW: Share Request System with Accept/Decline Workflow
// ============================================================
// Create a share request (invite user)
export async function createShareRequest(noteId: string, recipientEmail: string, permission: 'view' | 'comment' | 'edit' = 'view') {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
// Find note
const note = await prisma.note.findUnique({
where: { id: noteId }
});
if (!note) {
throw new Error('Note not found');
}
// Check ownership
if (note.userId !== session.user.id) {
throw new Error('Only the owner can share notes');
}
// Find recipient by email
const recipient = await prisma.user.findUnique({
where: { email: recipientEmail }
});
if (!recipient) {
throw new Error('User not found');
}
// Don't share with yourself
if (recipient.id === session.user.id) {
throw new Error('You cannot share with yourself');
}
// Check if share already exists
const existingShare = await prisma.noteShare.findUnique({
where: {
noteId_userId: {
noteId,
userId: recipient.id
}
}
});
if (existingShare) {
if (existingShare.status === 'declined') {
// Reactivate declined share
await prisma.noteShare.update({
where: { id: existingShare.id },
data: {
status: 'pending',
notifiedAt: new Date(),
permission
}
});
} else if (existingShare.status === 'removed') {
// Reactivate removed share
await prisma.noteShare.update({
where: { id: existingShare.id },
data: {
status: 'pending',
notifiedAt: new Date(),
permission
}
});
} else {
throw new Error('Note already shared with this user');
}
} else {
// Create new share request
await prisma.noteShare.create({
data: {
noteId,
userId: recipient.id,
sharedBy: session.user.id,
status: 'pending',
permission,
notifiedAt: new Date()
}
});
}
// Don't revalidatePath here - it would close all open dialogs!
// The UI will update via optimistic updates in the dialog
return { success: true };
} catch (error: any) {
console.error('Error creating share request:', error);
throw error;
}
}
// Get pending share requests for current user
export async function getPendingShareRequests() {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const pendingRequests = await prisma.noteShare.findMany({
where: {
userId: session.user.id,
status: 'pending'
},
include: {
note: {
select: {
id: true,
title: true,
content: true,
color: true,
createdAt: true
}
},
sharer: {
select: {
id: true,
name: true,
email: true,
image: true
}
}
},
orderBy: {
createdAt: 'desc'
}
});
return pendingRequests;
} catch (error: any) {
console.error('Error fetching pending share requests:', error);
throw error;
}
}
// Respond to share request (accept/decline)
export async function respondToShareRequest(shareId: string, action: 'accept' | 'decline') {
const session = await auth();
if (!session?.user?.id) throw new Error('Unauthorized');
try {
const share = await prisma.noteShare.findUnique({
where: { id: shareId },
include: {
note: true,
sharer: true
}
});
if (!share) {
throw new Error('Share request not found');
}
// Verify this share belongs to current user
if (share.userId !== session.user.id) {
throw new Error('Unauthorized');
}
// Update share status
const updatedShare = await prisma.noteShare.update({
where: { id: shareId },
data: {
status: action === 'accept' ? 'accepted' : 'declined',
respondedAt: new Date()
},
include: {
note: {
select: {
title: true
}
}
}
});
// Revalidate all relevant cache tags
revalidatePath('/home');
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('/home');
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('/home');
return { success: true };
} catch (error: any) {
console.error('Error leaving shared note:', error);
throw error;
}
}