Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m7s
Replaced ~100+ hardcoded French and English text strings across 30+ components with proper i18n t() calls. Added 57 new translation keys to all 15 locale files (ar, de, en, es, fa, fr, hi, it, ja, ko, nl, pl, pt, ru, zh). Key changes: - contextual-ai-chat.tsx: 30 French strings → t() (actions, toasts, labels, placeholders) - ai-chat.tsx: 15 French/English strings → t() (header, tabs, welcome, insights, history) - note-inline-editor.tsx: 20 French fallbacks removed (toolbar, save status, checklist) - lab-skeleton.tsx: French loading text → t() - admin-header.tsx, header.tsx, editor-connections-section.tsx: French fallbacks removed - New AI chat component, agent cards, sidebar, settings panel i18n cleanup Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1767 lines
51 KiB
TypeScript
1767 lines
51 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 { parseNote as parseNoteUtil, cosineSimilarity, 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'
|
|
import { cleanupNoteImages, parseImageUrls, deleteImageFileSafely } from '@/lib/image-cleanup'
|
|
import { getAISettings } from '@/app/actions/ai-settings'
|
|
|
|
|
|
/**
|
|
* 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,
|
|
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,
|
|
// embedding: false — volontairement omis (économise ~6KB JSON/note)
|
|
} as const
|
|
|
|
// Wrapper for parseNote (embedding validation removed - embeddings are now in NoteEmbedding table)
|
|
function parseNote(dbNote: any): Note {
|
|
return parseNoteUtil(dbNote)
|
|
}
|
|
|
|
// 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.
|
|
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
|
|
*/
|
|
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
|
|
try {
|
|
const nbScope = notebookId ?? null
|
|
|
|
// 1. Bulk-upsert les nouveaux labels via upsert en transaction
|
|
if (noteLabels.length > 0) {
|
|
const trimmedNames = [...new Set(
|
|
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
|
|
)]
|
|
|
|
if (trimmedNames.length > 0) {
|
|
await prisma.$transaction(
|
|
trimmedNames.map(name =>
|
|
prisma.label.upsert({
|
|
where: { notebookId_name: { notebookId: nbScope ?? '', name } as any },
|
|
update: {},
|
|
create: {
|
|
userId,
|
|
name,
|
|
color: getHashColor(name),
|
|
notebookId: nbScope,
|
|
},
|
|
})
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur
|
|
const [allNotes, allLabels] = await Promise.all([
|
|
prisma.note.findMany({
|
|
where: { userId },
|
|
select: {
|
|
notebookId: true,
|
|
labels: true,
|
|
labelRelations: { select: { name: true } },
|
|
},
|
|
}),
|
|
prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true, notebookId: 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)
|
|
}
|
|
}
|
|
|
|
// 3. Supprimer les labels orphelins
|
|
const orphanIds = allLabels
|
|
.filter(label => {
|
|
const key = labelScopeKey(label.notebookId, label.name)
|
|
return key && !usedLabelsSet.has(key)
|
|
})
|
|
.map(label => label.id)
|
|
|
|
if (orphanIds.length > 0) {
|
|
// Dissocier les relations avant la suppression
|
|
await prisma.label.updateMany({
|
|
where: { id: { in: orphanIds } },
|
|
data: {} // Nécessaire pour trigger le middleware
|
|
})
|
|
// Supprimer en une seule requête
|
|
await prisma.label.deleteMany({
|
|
where: { id: { in: orphanIds } }
|
|
})
|
|
}
|
|
} 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,
|
|
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' }
|
|
}
|
|
}
|
|
|
|
// 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 []
|
|
}
|
|
}
|
|
|
|
// Search notes - DB-side filtering (fast) with optional semantic search
|
|
// 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);
|
|
}
|
|
|
|
// DB-side keyword search using LIKE — much faster than loading all notes in memory
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: false,
|
|
trashedAt: null,
|
|
OR: [
|
|
{ title: { contains: query } },
|
|
{ content: { contains: query } },
|
|
{ labels: { contains: query } },
|
|
],
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
});
|
|
|
|
return notes.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,
|
|
trashedAt: null,
|
|
...(notebookId !== undefined ? { notebookId } : {})
|
|
},
|
|
include: { noteEmbedding: true }
|
|
});
|
|
|
|
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.noteEmbedding?.embedding) {
|
|
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.noteEmbedding.embedding));
|
|
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
|
|
}
|
|
|
|
return {
|
|
note,
|
|
keywordMatch,
|
|
semanticMatch,
|
|
similarity
|
|
};
|
|
}).filter(r => r.keywordMatch || r.semanticMatch);
|
|
|
|
// Parse and add match info
|
|
return results.map(r => {
|
|
const parsed = parseNote(r.note);
|
|
|
|
// Determine match type
|
|
let matchType: 'exact' | 'related' | null = null;
|
|
if (r.semanticMatch) {
|
|
matchType = 'related';
|
|
} else if (r.keywordMatch) {
|
|
matchType = 'exact';
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
matchType
|
|
};
|
|
});
|
|
}
|
|
|
|
// Create a new note
|
|
export async function createNote(data: {
|
|
title?: string
|
|
content: string
|
|
color?: string
|
|
type?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[]
|
|
labels?: string[]
|
|
images?: string[]
|
|
links?: any[]
|
|
isArchived?: boolean
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
size?: 'small' | 'medium' | 'large'
|
|
autoGenerated?: boolean
|
|
aiProvider?: string
|
|
notebookId?: string | undefined // Assign note to a notebook if provided
|
|
skipRevalidation?: boolean // Option to prevent full page refresh for smooth optimistic UI updates
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
// Save note to DB immediately (fast!) — AI operations run in background after
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
userId: session.user.id,
|
|
title: data.title || null,
|
|
content: data.content,
|
|
color: data.color || 'default',
|
|
type: data.type || 'text',
|
|
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
|
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
|
|
images: data.images ? JSON.stringify(data.images) : null,
|
|
links: data.links ? JSON.stringify(data.links) : null,
|
|
isArchived: data.isArchived || false,
|
|
reminder: data.reminder || null,
|
|
isMarkdown: data.isMarkdown || false,
|
|
size: data.size || 'small',
|
|
autoGenerated: data.autoGenerated || null,
|
|
aiProvider: data.aiProvider || null,
|
|
notebookId: data.notebookId || null,
|
|
}
|
|
})
|
|
|
|
// Sync user-provided labels immediately (étiquettes rattachées au carnet de la note)
|
|
if (data.labels && data.labels.length > 0) {
|
|
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
|
|
}
|
|
|
|
if (!data.skipRevalidation) {
|
|
// Revalidate main page (handles both inbox and notebook views via query params)
|
|
revalidatePath('/')
|
|
}
|
|
|
|
// Fire-and-forget: run AI operations in background without blocking the response
|
|
const userId = session.user.id
|
|
const noteId = note.id
|
|
const content = data.content
|
|
const notebookId = data.notebookId
|
|
const hasUserLabels = data.labels && data.labels.length > 0
|
|
|
|
// Use setImmediate-like pattern to not block the response
|
|
;(async () => {
|
|
try {
|
|
// Background task 1: Generate embedding
|
|
const bgConfig = await getSystemConfig()
|
|
const provider = getAIProvider(bgConfig)
|
|
const embedding = await provider.getEmbeddings(content)
|
|
if (embedding) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: noteId },
|
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
|
update: { 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 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) {
|
|
await prisma.note.update({
|
|
where: { id: noteId },
|
|
data: { labels: JSON.stringify(appliedLabels) }
|
|
})
|
|
await syncLabels(userId, appliedLabels, notebookId ?? null)
|
|
if (!data.skipRevalidation) {
|
|
revalidatePath('/')
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('[BG] Auto-labeling failed:', error)
|
|
}
|
|
} 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?: '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
|
|
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 }
|
|
})
|
|
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.noteEmbedding.upsert({
|
|
where: { noteId: noteId },
|
|
create: { noteId: noteId, embedding: JSON.stringify(embedding) },
|
|
update: { embedding: JSON.stringify(embedding) }
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('[BG] Embedding regeneration failed:', e);
|
|
}
|
|
})()
|
|
}
|
|
|
|
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
|
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
|
// Explicitly handle size to ensure it propagates
|
|
if ('size' in data && data.size) updateData.size = data.size
|
|
|
|
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
|
// (size, color, isPinned, isArchived are properties, not content)
|
|
// skipContentTimestamp=true is used by the inline editor to avoid bumping "Récent" on every auto-save
|
|
const contentFields = ['title', 'content', 'checkItems', 'images', 'links']
|
|
const isContentChange = contentFields.some(field => field in data)
|
|
if (isContentChange && !options?.skipContentTimestamp) {
|
|
updateData.contentUpdatedAt = new Date()
|
|
}
|
|
|
|
const note = await prisma.note.update({
|
|
where: { id, userId: session.user.id },
|
|
data: updateData
|
|
})
|
|
|
|
// Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
|
|
const notebookMoved =
|
|
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
|
if (data.labels !== undefined || notebookMoved) {
|
|
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
|
|
const effectiveNotebookId =
|
|
data.notebookId !== undefined ? data.notebookId : oldNotebookId
|
|
await syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null)
|
|
}
|
|
|
|
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
|
// Content edits (title, content, size, color) use optimistic UI — no refresh needed
|
|
const structuralFields = ['isPinned', 'isArchived', 'labels', 'notebookId']
|
|
const isStructuralChange = structuralFields.some(field => field in data)
|
|
|
|
if (isStructuralChange && !options?.skipRevalidation) {
|
|
revalidatePath('/')
|
|
revalidatePath(`/note/${id}`)
|
|
|
|
if (data.notebookId !== undefined && data.notebookId !== oldNotebookId) {
|
|
if (oldNotebookId) {
|
|
revalidatePath(`/notebook/${oldNotebookId}`)
|
|
}
|
|
if (data.notebookId) {
|
|
revalidatePath(`/notebook/${data.notebookId}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error updating note:', error)
|
|
throw error // Re-throw the REAL error, not a generic one
|
|
}
|
|
}
|
|
|
|
// 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('/')
|
|
}
|
|
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('/')
|
|
}
|
|
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('/')
|
|
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('/')
|
|
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 {
|
|
// Fetch trashed notes with images before deleting
|
|
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 }
|
|
}
|
|
})
|
|
|
|
// Clean up image files for all deleted notes
|
|
for (const note of trashedNotes) {
|
|
const imageUrls = parseImageUrls(note.images)
|
|
if (imageUrls.length > 0) {
|
|
await cleanupNoteImages(note.id, imageUrls)
|
|
}
|
|
}
|
|
|
|
await syncLabels(session.user.id, [])
|
|
revalidatePath('/trash')
|
|
revalidatePath('/')
|
|
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 {
|
|
return await prisma.note.count({
|
|
where: {
|
|
userId: session.user.id,
|
|
trashedAt: { not: null }
|
|
}
|
|
})
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
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, 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('/')
|
|
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 {
|
|
// 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('/')
|
|
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) {
|
|
await prisma.noteEmbedding.upsert({
|
|
where: { noteId: note.id },
|
|
create: { noteId: note.id, embedding: JSON.stringify(embedding) },
|
|
update: { 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 {
|
|
// Fetch own notes + shared notes in parallel — no embedding to keep transfer fast
|
|
const [ownNotes, acceptedShares] = await Promise.all([
|
|
prisma.note.findMany({
|
|
where: {
|
|
userId,
|
|
trashedAt: null,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
},
|
|
select: NOTE_LIST_SELECT,
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
}),
|
|
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('/') // 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('/');
|
|
|
|
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;
|
|
}
|
|
}
|