- RTL: force dir=rtl on LabelFilter, NotesViewToggle, LabelManagementDialog - i18n: add missing keys (notifications, privacy, edit/preview, AI translate/undo) - Settings pages: convert to Server Components (general, appearance) + loading skeleton - AI menu: add Translate option (10 languages) + Undo AI button in toolbar - Fix: saveInline uses REST API instead of Server Action → eliminates all implicit refreshes in list mode - Fix: NotesTabsView notes sync effect preserves selected note on content changes - Fix: auto-tag suggestions now filter already-assigned labels - Fix: color change in card view uses local state (no refresh) - Fix: nav links use <Link> for prefetching (Settings, Admin) - Fix: suppress duplicate label suggestions already on note - Route: add /api/ai/translate endpoint
1498 lines
44 KiB
TypeScript
1498 lines
44 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, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
||
import { getSystemConfig, getConfigNumber, getConfigBoolean, SEARCH_DEFAULTS } from '@/lib/config'
|
||
import { contextualAutoTagService } from '@/lib/ai/services/contextual-auto-tag.service'
|
||
|
||
// Wrapper for parseNote that validates embeddings
|
||
function parseNote(dbNote: any): Note {
|
||
const note = parseNoteUtil(dbNote)
|
||
|
||
// Validate embedding if present
|
||
if (note.embedding && Array.isArray(note.embedding)) {
|
||
const validation = validateEmbedding(note.embedding)
|
||
if (!validation.valid) {
|
||
return {
|
||
...note,
|
||
embedding: null
|
||
}
|
||
}
|
||
}
|
||
|
||
return note
|
||
}
|
||
|
||
// Helper to get hash color for labels (copied from utils)
|
||
function getHashColor(name: string): string {
|
||
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
||
let hash = 0
|
||
for (let i = 0; i < name.length; i++) {
|
||
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
||
}
|
||
return colors[Math.abs(hash) % colors.length]
|
||
}
|
||
|
||
/** Clé stable (carnet + nom) : les étiquettes sont uniques par (notebookId, name) côté Prisma */
|
||
function labelScopeKey(notebookId: string | null | undefined, rawName: string): string {
|
||
const name = rawName.trim().toLowerCase()
|
||
if (!name) return ''
|
||
const nb = notebookId ?? ''
|
||
return `${nb}\u0000${name}`
|
||
}
|
||
|
||
function collectLabelNamesFromNote(note: {
|
||
labels: string | null
|
||
labelRelations?: { name: string }[]
|
||
}): string[] {
|
||
const names: string[] = []
|
||
if (note.labels) {
|
||
try {
|
||
const parsed: unknown = JSON.parse(note.labels)
|
||
if (Array.isArray(parsed)) {
|
||
for (const l of parsed) {
|
||
if (typeof l === 'string' && l.trim()) names.push(l.trim())
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('[SYNC] Failed to parse labels:', e)
|
||
}
|
||
}
|
||
for (const rel of note.labelRelations ?? []) {
|
||
if (rel.name?.trim()) names.push(rel.name.trim())
|
||
}
|
||
return names
|
||
}
|
||
|
||
/**
|
||
* Sync Label rows with Note.labels + labelRelations.
|
||
* Les étiquettes d’un carnet doivent avoir le même notebookId que les notes (liste latérale / filtres).
|
||
*/
|
||
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
|
||
try {
|
||
const nbScope = notebookId ?? null
|
||
|
||
if (noteLabels.length > 0) {
|
||
let scoped = await prisma.label.findMany({
|
||
where: { userId },
|
||
select: { id: true, name: true, notebookId: true },
|
||
})
|
||
for (const labelName of noteLabels) {
|
||
if (!labelName?.trim()) continue
|
||
const trimmed = labelName.trim()
|
||
const exists = scoped.some(
|
||
l => (l.notebookId ?? null) === nbScope && l.name.toLowerCase() === trimmed.toLowerCase()
|
||
)
|
||
if (exists) continue
|
||
try {
|
||
const created = await prisma.label.create({
|
||
data: {
|
||
userId,
|
||
name: trimmed,
|
||
color: getHashColor(trimmed),
|
||
notebookId: nbScope,
|
||
},
|
||
})
|
||
scoped.push(created)
|
||
} catch (e: any) {
|
||
if (e.code !== 'P2002') {
|
||
console.error(`[SYNC] Failed to create label "${trimmed}":`, e)
|
||
}
|
||
scoped = await prisma.label.findMany({
|
||
where: { userId },
|
||
select: { id: true, name: true, notebookId: true },
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
const allNotes = await prisma.note.findMany({
|
||
where: { userId },
|
||
select: {
|
||
notebookId: true,
|
||
labels: true,
|
||
labelRelations: { select: { name: true } },
|
||
},
|
||
})
|
||
|
||
const usedLabelsSet = new Set<string>()
|
||
for (const note of allNotes) {
|
||
for (const name of collectLabelNamesFromNote(note)) {
|
||
const key = labelScopeKey(note.notebookId, name)
|
||
if (key) usedLabelsSet.add(key)
|
||
}
|
||
}
|
||
|
||
const allLabels = await prisma.label.findMany({ where: { userId } })
|
||
for (const label of allLabels) {
|
||
const key = labelScopeKey(label.notebookId, label.name)
|
||
if (!key || usedLabelsSet.has(key)) continue
|
||
try {
|
||
await prisma.label.update({
|
||
where: { id: label.id },
|
||
data: { notes: { set: [] } },
|
||
})
|
||
await prisma.label.delete({ where: { id: label.id } })
|
||
} catch (e) {
|
||
console.error('[SYNC] Failed to delete orphan label:', e)
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Fatal error in syncLabels:', error)
|
||
}
|
||
}
|
||
|
||
/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */
|
||
export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
|
||
const session = await auth()
|
||
if (!session?.user?.id) return
|
||
const note = await prisma.note.findFirst({
|
||
where: { id: noteId, userId: session.user.id },
|
||
select: { labels: true },
|
||
})
|
||
if (!note) return
|
||
let labels: string[] = []
|
||
if (note.labels) {
|
||
try {
|
||
const raw = JSON.parse(note.labels) as unknown
|
||
if (Array.isArray(raw)) {
|
||
labels = raw.filter((x): x is string => typeof x === 'string')
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
await syncLabels(session.user.id, labels, newNotebookId)
|
||
}
|
||
|
||
// Get all notes (non-archived by default)
|
||
export async function getNotes(includeArchived = false) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
try {
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
...(includeArchived ? {} : { isArchived: false }),
|
||
},
|
||
orderBy: [
|
||
{ isPinned: 'desc' },
|
||
{ order: 'asc' },
|
||
{ updatedAt: 'desc' }
|
||
]
|
||
})
|
||
|
||
return notes.map(parseNote)
|
||
} catch (error) {
|
||
console.error('Error fetching notes:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Get notes with reminders (upcoming, overdue, done)
|
||
export async function getNotesWithReminders() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
try {
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
isArchived: false,
|
||
reminder: { not: null }
|
||
},
|
||
orderBy: { reminder: 'asc' }
|
||
})
|
||
|
||
return notes.map(parseNote)
|
||
} catch (error) {
|
||
console.error('Error fetching notes with reminders:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Mark a reminder as done / undone
|
||
export async function toggleReminderDone(noteId: string, done: boolean) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return { error: 'Unauthorized' }
|
||
|
||
try {
|
||
await prisma.note.update({
|
||
where: { id: noteId, userId: session.user.id },
|
||
data: { isReminderDone: done }
|
||
})
|
||
revalidatePath('/reminders')
|
||
return { success: true }
|
||
} catch (error) {
|
||
console.error('Error toggling reminder done:', error)
|
||
return { error: 'Failed to update reminder' }
|
||
}
|
||
}
|
||
|
||
// Get archived notes only
|
||
export async function getArchivedNotes() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
try {
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
isArchived: true
|
||
},
|
||
orderBy: { updatedAt: 'desc' }
|
||
})
|
||
|
||
return notes.map(parseNote)
|
||
} catch (error) {
|
||
console.error('Error fetching archived notes:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Search notes - SIMPLE AND EFFECTIVE
|
||
// Supports contextual search within notebook (IA5)
|
||
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
try {
|
||
// If query empty, return all notes
|
||
if (!query || !query.trim()) {
|
||
return await getAllNotes();
|
||
}
|
||
|
||
// If semantic search is requested, use the full implementation
|
||
if (useSemantic) {
|
||
return await semanticSearch(query, session.user.id, notebookId); // NEW: Pass notebookId for contextual search (IA5)
|
||
}
|
||
|
||
// Get all notes
|
||
const allNotes = await prisma.note.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
isArchived: false
|
||
}
|
||
});
|
||
|
||
const queryLower = query.toLowerCase().trim();
|
||
|
||
// SIMPLE FILTER: check if query is in title OR content OR labels
|
||
const filteredNotes = allNotes.filter(note => {
|
||
const title = (note.title || '').toLowerCase();
|
||
const content = note.content.toLowerCase();
|
||
const labels = note.labels ? JSON.parse(note.labels) : [];
|
||
|
||
// Check if query exists in title, content, or any label
|
||
return title.includes(queryLower) ||
|
||
content.includes(queryLower) ||
|
||
labels.some((label: string) => label.toLowerCase().includes(queryLower));
|
||
});
|
||
|
||
return filteredNotes.map(parseNote);
|
||
} catch (error) {
|
||
console.error('Search error:', error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
// Semantic search with AI embeddings - SIMPLE VERSION
|
||
// Supports contextual search within notebook (IA5)
|
||
async function semanticSearch(query: string, userId: string, notebookId?: string) {
|
||
const allNotes = await prisma.note.findMany({
|
||
where: {
|
||
userId: userId,
|
||
isArchived: false,
|
||
...(notebookId !== undefined ? { notebookId } : {}) // NEW: Filter by notebook (IA5)
|
||
}
|
||
});
|
||
|
||
const queryLower = query.toLowerCase().trim();
|
||
|
||
// Get query embedding
|
||
let queryEmbedding: number[] | null = null;
|
||
try {
|
||
const provider = getAIProvider(await getSystemConfig());
|
||
queryEmbedding = await provider.getEmbeddings(query);
|
||
} catch (e) {
|
||
console.error('Failed to generate query embedding:', e);
|
||
// Fallback to simple keyword search
|
||
queryEmbedding = null;
|
||
}
|
||
|
||
// Filter notes: keyword match OR semantic match (threshold 30%)
|
||
const results = allNotes.map(note => {
|
||
const title = (note.title || '').toLowerCase();
|
||
const content = note.content.toLowerCase();
|
||
const labels = note.labels ? JSON.parse(note.labels) : [];
|
||
|
||
// Keyword match
|
||
const keywordMatch = title.includes(queryLower) ||
|
||
content.includes(queryLower) ||
|
||
labels.some((l: string) => l.toLowerCase().includes(queryLower));
|
||
|
||
// Semantic match (if embedding available)
|
||
let semanticMatch = false;
|
||
let similarity = 0;
|
||
if (queryEmbedding && note.embedding) {
|
||
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.embedding));
|
||
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
|
||
}
|
||
|
||
return {
|
||
note,
|
||
keywordMatch,
|
||
semanticMatch,
|
||
similarity
|
||
};
|
||
}).filter(r => r.keywordMatch || r.semanticMatch);
|
||
|
||
// Parse and add match info
|
||
return results.map(r => {
|
||
const parsed = parseNote(r.note);
|
||
|
||
// Determine match type
|
||
let matchType: 'exact' | 'related' | null = null;
|
||
if (r.semanticMatch) {
|
||
matchType = 'related';
|
||
} else if (r.keywordMatch) {
|
||
matchType = 'exact';
|
||
}
|
||
|
||
return {
|
||
...parsed,
|
||
matchType
|
||
};
|
||
});
|
||
}
|
||
|
||
// Create a new note
|
||
export async function createNote(data: {
|
||
title?: string
|
||
content: string
|
||
color?: string
|
||
type?: 'text' | 'checklist'
|
||
checkItems?: CheckItem[]
|
||
labels?: string[]
|
||
images?: string[]
|
||
links?: any[]
|
||
isArchived?: boolean
|
||
reminder?: Date | null
|
||
isMarkdown?: boolean
|
||
size?: 'small' | 'medium' | 'large'
|
||
autoGenerated?: boolean
|
||
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',
|
||
embedding: null, // Generated in background
|
||
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
|
||
autoGenerated: data.autoGenerated || null,
|
||
notebookId: data.notebookId || null,
|
||
}
|
||
})
|
||
|
||
// Sync user-provided labels immediately (étiquettes rattachées au carnet de la note)
|
||
if (data.labels && data.labels.length > 0) {
|
||
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
|
||
}
|
||
|
||
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 provider = getAIProvider(await getSystemConfig())
|
||
const embedding = await provider.getEmbeddings(content)
|
||
if (embedding) {
|
||
await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: { embedding: JSON.stringify(embedding) }
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('[BG] Embedding generation failed:', e)
|
||
}
|
||
|
||
// Background task 2: Auto-labeling (only if no user labels and has notebook)
|
||
if (!hasUserLabels && notebookId) {
|
||
try {
|
||
const autoLabelingEnabled = await getConfigBoolean('AUTO_LABELING_ENABLED', true)
|
||
const autoLabelingConfidence = await getConfigNumber('AUTO_LABELING_CONFIDENCE_THRESHOLD', 70)
|
||
|
||
if (autoLabelingEnabled) {
|
||
const suggestions = await contextualAutoTagService.suggestLabels(
|
||
content,
|
||
notebookId,
|
||
userId
|
||
)
|
||
|
||
const appliedLabels = suggestions
|
||
.filter(s => s.confidence >= autoLabelingConfidence)
|
||
.map(s => s.label)
|
||
|
||
if (appliedLabels.length > 0) {
|
||
await prisma.note.update({
|
||
where: { id: noteId },
|
||
data: { labels: JSON.stringify(appliedLabels) }
|
||
})
|
||
await syncLabels(userId, appliedLabels, notebookId ?? null)
|
||
if (!data.skipRevalidation) {
|
||
revalidatePath('/')
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('[BG] Auto-labeling failed:', error)
|
||
}
|
||
}
|
||
})()
|
||
|
||
return parseNote(note)
|
||
} catch (error) {
|
||
console.error('Error creating note:', error)
|
||
throw new Error('Failed to create note')
|
||
}
|
||
}
|
||
|
||
// Update a note
|
||
export async function updateNote(id: string, data: {
|
||
title?: string | null
|
||
content?: string
|
||
color?: string
|
||
isPinned?: boolean
|
||
isArchived?: boolean
|
||
type?: 'text' | 'checklist'
|
||
checkItems?: CheckItem[] | null
|
||
labels?: string[] | null
|
||
images?: string[] | null
|
||
links?: any[] | null
|
||
reminder?: Date | null
|
||
isMarkdown?: boolean
|
||
size?: 'small' | 'medium' | 'large'
|
||
autoGenerated?: boolean | null
|
||
notebookId?: string | null
|
||
}, 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.note.update({
|
||
where: { id: noteId },
|
||
data: { embedding: JSON.stringify(embedding) }
|
||
})
|
||
}
|
||
} catch (e) {
|
||
console.error('[BG] Embedding regeneration failed:', e);
|
||
}
|
||
})()
|
||
}
|
||
|
||
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
||
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
||
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
||
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
||
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
||
// Explicitly handle size to ensure it propagates
|
||
if ('size' in data && data.size) updateData.size = data.size
|
||
|
||
// Only update contentUpdatedAt for actual content changes, NOT for property changes
|
||
// (size, color, isPinned, isArchived are properties, not content)
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// Delete a note
|
||
export async function deleteNote(id: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
||
|
||
// Sync labels with empty array to trigger cleanup of any orphans
|
||
// The syncLabels function will scan all remaining notes and clean up unused labels
|
||
await syncLabels(session.user.id, [])
|
||
|
||
revalidatePath('/')
|
||
return { success: true }
|
||
} catch (error) {
|
||
console.error('Error deleting note:', error)
|
||
throw new Error('Failed to delete note')
|
||
}
|
||
}
|
||
|
||
// Toggle functions
|
||
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
||
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
||
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
|
||
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
|
||
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null }) }
|
||
|
||
// Update note size WITHOUT revalidation - client uses optimistic updates
|
||
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
|
||
|
||
const result = await updateNote(id, { size })
|
||
|
||
return result
|
||
}
|
||
|
||
// Get all unique labels
|
||
export async function getAllLabels() {
|
||
try {
|
||
const notes = await prisma.note.findMany({ select: { labels: true } })
|
||
const labelsSet = new Set<string>()
|
||
notes.forEach((note: any) => {
|
||
const labels = note.labels ? JSON.parse(note.labels) : null
|
||
if (labels) labels.forEach((label: string) => labelsSet.add(label))
|
||
})
|
||
return Array.from(labelsSet).sort()
|
||
} catch (error) {
|
||
console.error('Error fetching labels:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Reorder
|
||
export async function reorderNotes(draggedId: string, targetId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
try {
|
||
const draggedNote = await prisma.note.findUnique({ where: { id: draggedId, userId: session.user.id } })
|
||
const targetNote = await prisma.note.findUnique({ where: { id: targetId, userId: session.user.id } })
|
||
if (!draggedNote || !targetNote) throw new Error('Notes not found')
|
||
const allNotes = await prisma.note.findMany({
|
||
where: { userId: session.user.id, isPinned: draggedNote.isPinned, isArchived: false },
|
||
orderBy: { order: 'asc' }
|
||
})
|
||
const reorderedNotes = allNotes.filter((n: any) => n.id !== draggedId)
|
||
const targetIndex = reorderedNotes.findIndex((n: any) => n.id === targetId)
|
||
reorderedNotes.splice(targetIndex, 0, draggedNote)
|
||
const updates = reorderedNotes.map((note: any, index: number) =>
|
||
prisma.note.update({ where: { id: note.id }, data: { order: index } })
|
||
)
|
||
await prisma.$transaction(updates)
|
||
revalidatePath('/')
|
||
return { success: true }
|
||
} catch (error) {
|
||
throw new Error('Failed to reorder notes')
|
||
}
|
||
}
|
||
|
||
export async function updateFullOrder(ids: string[]) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
const userId = session.user.id;
|
||
try {
|
||
const updates = ids.map((id: string, index: number) =>
|
||
prisma.note.update({ where: { id, userId }, data: { order: index } })
|
||
)
|
||
await prisma.$transaction(updates)
|
||
revalidatePath('/')
|
||
return { success: true }
|
||
} catch (error) {
|
||
throw new Error('Failed to update order')
|
||
}
|
||
}
|
||
|
||
// Optimized version for drag & drop - no revalidation to prevent double refresh
|
||
export async function updateFullOrderWithoutRevalidation(ids: string[]) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
const userId = session.user.id;
|
||
try {
|
||
const updates = ids.map((id: string, index: number) =>
|
||
prisma.note.update({ where: { id, userId }, data: { order: index } })
|
||
)
|
||
await prisma.$transaction(updates)
|
||
return { success: true }
|
||
} catch (error) {
|
||
throw new Error('Failed to update order')
|
||
}
|
||
}
|
||
|
||
// Maintenance - Sync all labels and clean up orphans (par carnet, aligné sur syncLabels)
|
||
export async function cleanupAllOrphans() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
const userId = session.user.id;
|
||
let createdCount = 0;
|
||
let deletedCount = 0;
|
||
let errors: any[] = [];
|
||
try {
|
||
const allNotes = await prisma.note.findMany({
|
||
where: { userId },
|
||
select: {
|
||
notebookId: true,
|
||
labels: true,
|
||
labelRelations: { select: { name: true } },
|
||
},
|
||
})
|
||
|
||
const usedSet = new Set<string>()
|
||
for (const note of allNotes) {
|
||
for (const name of collectLabelNamesFromNote(note)) {
|
||
const key = labelScopeKey(note.notebookId, name)
|
||
if (key) usedSet.add(key)
|
||
}
|
||
}
|
||
|
||
let allScoped = await prisma.label.findMany({
|
||
where: { userId },
|
||
select: { id: true, name: true, notebookId: true },
|
||
})
|
||
|
||
const ensuredPairs = new Set<string>()
|
||
for (const note of allNotes) {
|
||
for (const name of collectLabelNamesFromNote(note)) {
|
||
const key = labelScopeKey(note.notebookId, name)
|
||
if (!key || ensuredPairs.has(key)) continue
|
||
ensuredPairs.add(key)
|
||
const trimmed = name.trim()
|
||
const nb = note.notebookId ?? null
|
||
const exists = allScoped.some(
|
||
l => (l.notebookId ?? null) === nb && l.name.toLowerCase() === trimmed.toLowerCase()
|
||
)
|
||
if (exists) continue
|
||
try {
|
||
const created = await prisma.label.create({
|
||
data: {
|
||
userId,
|
||
name: trimmed,
|
||
color: getHashColor(trimmed),
|
||
notebookId: nb,
|
||
},
|
||
})
|
||
allScoped.push(created)
|
||
createdCount++
|
||
} catch (e: any) {
|
||
console.error(`Failed to create label:`, e)
|
||
errors.push({ label: trimmed, notebookId: nb, error: e.message, code: e.code })
|
||
allScoped = await prisma.label.findMany({
|
||
where: { userId },
|
||
select: { id: true, name: true, notebookId: true },
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
allScoped = await prisma.label.findMany({
|
||
where: { userId },
|
||
select: { id: true, name: true, notebookId: true },
|
||
})
|
||
for (const label of allScoped) {
|
||
const key = labelScopeKey(label.notebookId, label.name)
|
||
if (!key || usedSet.has(key)) continue
|
||
try {
|
||
await prisma.label.update({
|
||
where: { id: label.id },
|
||
data: { notes: { set: [] } },
|
||
})
|
||
await prisma.label.delete({ where: { id: label.id } })
|
||
deletedCount++
|
||
} catch (e: any) {
|
||
console.error(`Failed to delete orphan ${label.id}:`, e)
|
||
errors.push({ labelId: label.id, name: label.name, error: e?.message, code: e?.code })
|
||
}
|
||
}
|
||
|
||
revalidatePath('/')
|
||
revalidatePath('/settings')
|
||
return {
|
||
success: true,
|
||
created: createdCount,
|
||
deleted: deletedCount,
|
||
errors,
|
||
message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}`
|
||
}
|
||
} catch (error) {
|
||
console.error('[CLEANUP] Fatal error:', error);
|
||
throw new Error('Failed to cleanup database')
|
||
}
|
||
}
|
||
|
||
export async function syncAllEmbeddings() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
const userId = session.user.id;
|
||
let updatedCount = 0;
|
||
try {
|
||
const notesToSync = await prisma.note.findMany({ where: { userId, embedding: null } })
|
||
const provider = getAIProvider(await getSystemConfig());
|
||
for (const note of notesToSync) {
|
||
if (!note.content) continue;
|
||
try {
|
||
const embedding = await provider.getEmbeddings(note.content);
|
||
if (embedding) {
|
||
await prisma.note.update({ where: { id: note.id }, data: { embedding: JSON.stringify(embedding) } })
|
||
updatedCount++;
|
||
}
|
||
} catch (e) { }
|
||
}
|
||
return { success: true, count: updatedCount }
|
||
} catch (error: any) {
|
||
throw new Error(`Failed to sync embeddings: ${error.message}`)
|
||
}
|
||
}
|
||
|
||
// Get all notes including those shared with the user
|
||
export async function getAllNotes(includeArchived = false) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
const userId = session.user.id;
|
||
|
||
try {
|
||
// Get user's own notes
|
||
const ownNotes = await prisma.note.findMany({
|
||
where: {
|
||
userId: userId,
|
||
...(includeArchived ? {} : { isArchived: false }),
|
||
},
|
||
orderBy: [
|
||
{ isPinned: 'desc' },
|
||
{ order: 'asc' },
|
||
{ updatedAt: 'desc' }
|
||
]
|
||
})
|
||
|
||
// Get notes shared with user via NoteShare (accepted only)
|
||
const acceptedShares = await prisma.noteShare.findMany({
|
||
where: {
|
||
userId: userId,
|
||
status: 'accepted'
|
||
},
|
||
include: {
|
||
note: true
|
||
}
|
||
})
|
||
|
||
const sharedNotes = acceptedShares
|
||
.map(share => share.note)
|
||
.filter(note => includeArchived || !note.isArchived)
|
||
|
||
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
||
|
||
// Derive pinned and recent notes
|
||
const pinned = allNotes.filter((note: Note) => note.isPinned)
|
||
const sevenDaysAgo = new Date()
|
||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||
sevenDaysAgo.setHours(0, 0, 0, 0)
|
||
|
||
const recent = allNotes
|
||
.filter((note: Note) => {
|
||
return !note.isArchived && !note.dismissedFromRecent && note.contentUpdatedAt >= sevenDaysAgo
|
||
})
|
||
.sort((a, b) => new Date(b.contentUpdatedAt).getTime() - new Date(a.createdAt).getTime())
|
||
.slice(0, 3)
|
||
|
||
return allNotes
|
||
} catch (error) {
|
||
console.error('Error fetching notes:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Get pinned notes only
|
||
export async function getPinnedNotes(notebookId?: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
const userId = session.user.id;
|
||
|
||
try {
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId: userId,
|
||
isPinned: true,
|
||
isArchived: false,
|
||
...(notebookId !== undefined ? { notebookId } : {})
|
||
},
|
||
orderBy: [
|
||
{ order: 'asc' },
|
||
{ updatedAt: 'desc' }
|
||
]
|
||
})
|
||
|
||
return notes.map(parseNote)
|
||
} catch (error) {
|
||
console.error('Error fetching pinned notes:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Get recent notes (notes modified in the last 7 days)
|
||
// Get recent notes (notes modified in the last 7 days)
|
||
export async function getRecentNotes(limit: number = 3) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return [];
|
||
|
||
const userId = session.user.id;
|
||
|
||
try {
|
||
const sevenDaysAgo = new Date()
|
||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7)
|
||
sevenDaysAgo.setHours(0, 0, 0, 0) // Set to start of day
|
||
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId: userId,
|
||
contentUpdatedAt: { gte: sevenDaysAgo },
|
||
isArchived: false,
|
||
dismissedFromRecent: false // Filter out dismissed notes
|
||
},
|
||
orderBy: { contentUpdatedAt: 'desc' },
|
||
take: limit
|
||
})
|
||
|
||
return notes.map(parseNote)
|
||
} catch (error) {
|
||
console.error('Error fetching recent notes:', error)
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Dismiss a note from Recent section
|
||
export async function dismissFromRecent(id: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
await prisma.note.update({
|
||
where: { id, userId: session.user.id },
|
||
data: { dismissedFromRecent: true }
|
||
})
|
||
|
||
// revalidatePath('/') // Removed to prevent immediate refill of the list
|
||
return { success: true }
|
||
} catch (error) {
|
||
console.error('Error dismissing note from recent:', error)
|
||
throw new Error('Failed to dismiss note')
|
||
}
|
||
}
|
||
|
||
export async function getNoteById(noteId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return null;
|
||
|
||
const userId = session.user.id;
|
||
|
||
try {
|
||
const note = await prisma.note.findFirst({
|
||
where: {
|
||
id: noteId,
|
||
OR: [
|
||
{ userId: userId },
|
||
{
|
||
shares: {
|
||
some: {
|
||
userId: userId,
|
||
status: 'accepted'
|
||
}
|
||
}
|
||
}
|
||
]
|
||
}
|
||
})
|
||
|
||
if (!note) return null
|
||
|
||
return parseNote(note)
|
||
} catch (error) {
|
||
console.error('Error fetching note:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
// Add a collaborator to a note (updated to use new share request system)
|
||
export async function addCollaborator(noteId: string, userEmail: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
// Use the new share request system
|
||
const result = await createShareRequest(noteId, userEmail, 'view');
|
||
|
||
// Get user info for response
|
||
const targetUser = await prisma.user.findUnique({
|
||
where: { email: userEmail },
|
||
select: { id: true, name: true, email: true, image: true }
|
||
});
|
||
|
||
if (!targetUser) {
|
||
throw new Error('User not found')
|
||
}
|
||
|
||
return { success: true, user: targetUser }
|
||
} catch (error: any) {
|
||
console.error('Error adding collaborator:', error)
|
||
throw error
|
||
}
|
||
}
|
||
|
||
// Remove a collaborator from a note
|
||
export async function removeCollaborator(noteId: string, userId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId }
|
||
})
|
||
|
||
if (!note) {
|
||
throw new Error('Note not found')
|
||
}
|
||
|
||
if (note.userId !== session.user.id) {
|
||
throw new Error('You can only manage collaborators on your own notes')
|
||
}
|
||
|
||
// Delete the NoteShare record (cascades to remove access)
|
||
await prisma.noteShare.deleteMany({
|
||
where: {
|
||
noteId,
|
||
userId
|
||
}
|
||
})
|
||
|
||
// Don't revalidatePath here - it would close all open dialogs!
|
||
// The UI will update via optimistic updates in the dialog
|
||
return { success: true }
|
||
} catch (error: any) {
|
||
console.error('Error removing collaborator:', error)
|
||
throw new Error(error.message || 'Failed to remove collaborator')
|
||
}
|
||
}
|
||
|
||
// Get collaborators for a note
|
||
export async function getNoteCollaborators(noteId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId },
|
||
select: { userId: true }
|
||
})
|
||
|
||
if (!note) {
|
||
throw new Error('Note not found')
|
||
}
|
||
|
||
if (note.userId !== session.user.id) {
|
||
throw new Error('You do not have access to this note')
|
||
}
|
||
|
||
// Get all users who have been shared this note (any status)
|
||
const shares = await prisma.noteShare.findMany({
|
||
where: {
|
||
noteId
|
||
},
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
email: true,
|
||
image: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
return shares.map(share => share.user)
|
||
} catch (error: any) {
|
||
console.error('Error fetching collaborators:', error)
|
||
throw new Error(error.message || 'Failed to fetch collaborators')
|
||
}
|
||
}
|
||
|
||
// Get all users associated with a note (owner + collaborators)
|
||
export async function getNoteAllUsers(noteId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId },
|
||
select: { userId: true }
|
||
})
|
||
|
||
if (!note) {
|
||
throw new Error('Note not found')
|
||
}
|
||
|
||
// Check access via NoteShare
|
||
const share = await prisma.noteShare.findUnique({
|
||
where: {
|
||
noteId_userId: {
|
||
noteId,
|
||
userId: session.user.id
|
||
}
|
||
}
|
||
})
|
||
|
||
const hasAccess = note.userId === session.user.id || (share && share.status === 'accepted')
|
||
|
||
if (!hasAccess) {
|
||
throw new Error('You do not have access to this note')
|
||
}
|
||
|
||
// Get owner
|
||
const owner = await prisma.user.findUnique({
|
||
where: { id: note.userId! },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
email: true,
|
||
image: true
|
||
}
|
||
})
|
||
|
||
if (!owner) {
|
||
throw new Error('Owner not found')
|
||
}
|
||
|
||
// Get collaborators (accepted shares)
|
||
const shares = await prisma.noteShare.findMany({
|
||
where: {
|
||
noteId,
|
||
status: 'accepted'
|
||
},
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
email: true,
|
||
image: true
|
||
}
|
||
}
|
||
}
|
||
})
|
||
|
||
const collaborators = shares.map(share => share.user)
|
||
|
||
// Return owner + collaborators
|
||
return [owner, ...collaborators]
|
||
} catch (error: any) {
|
||
console.error('Error fetching note users:', error)
|
||
throw new Error(error.message || 'Failed to fetch note users')
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// NEW: Share Request System with Accept/Decline Workflow
|
||
// ============================================================
|
||
|
||
// Create a share request (invite user)
|
||
export async function createShareRequest(noteId: string, recipientEmail: string, permission: 'view' | 'comment' | 'edit' = 'view') {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
// Find note
|
||
const note = await prisma.note.findUnique({
|
||
where: { id: noteId }
|
||
});
|
||
|
||
if (!note) {
|
||
throw new Error('Note not found');
|
||
}
|
||
|
||
// Check ownership
|
||
if (note.userId !== session.user.id) {
|
||
throw new Error('Only the owner can share notes');
|
||
}
|
||
|
||
// Find recipient by email
|
||
const recipient = await prisma.user.findUnique({
|
||
where: { email: recipientEmail }
|
||
});
|
||
|
||
if (!recipient) {
|
||
throw new Error('User not found');
|
||
}
|
||
|
||
// Don't share with yourself
|
||
if (recipient.id === session.user.id) {
|
||
throw new Error('You cannot share with yourself');
|
||
}
|
||
|
||
// Check if share already exists
|
||
const existingShare = await prisma.noteShare.findUnique({
|
||
where: {
|
||
noteId_userId: {
|
||
noteId,
|
||
userId: recipient.id
|
||
}
|
||
}
|
||
});
|
||
|
||
if (existingShare) {
|
||
if (existingShare.status === 'declined') {
|
||
// Reactivate declined share
|
||
await prisma.noteShare.update({
|
||
where: { id: existingShare.id },
|
||
data: {
|
||
status: 'pending',
|
||
notifiedAt: new Date(),
|
||
permission
|
||
}
|
||
});
|
||
} else if (existingShare.status === 'removed') {
|
||
// Reactivate removed share
|
||
await prisma.noteShare.update({
|
||
where: { id: existingShare.id },
|
||
data: {
|
||
status: 'pending',
|
||
notifiedAt: new Date(),
|
||
permission
|
||
}
|
||
});
|
||
} else {
|
||
throw new Error('Note already shared with this user');
|
||
}
|
||
} else {
|
||
// Create new share request
|
||
await prisma.noteShare.create({
|
||
data: {
|
||
noteId,
|
||
userId: recipient.id,
|
||
sharedBy: session.user.id,
|
||
status: 'pending',
|
||
permission,
|
||
notifiedAt: new Date()
|
||
}
|
||
});
|
||
}
|
||
|
||
// Don't revalidatePath here - it would close all open dialogs!
|
||
// The UI will update via optimistic updates in the dialog
|
||
return { success: true };
|
||
} catch (error: any) {
|
||
console.error('Error creating share request:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Get pending share requests for current user
|
||
export async function getPendingShareRequests() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const pendingRequests = await prisma.noteShare.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
status: 'pending'
|
||
},
|
||
include: {
|
||
note: {
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
color: true,
|
||
createdAt: true
|
||
}
|
||
},
|
||
sharer: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
email: true,
|
||
image: true
|
||
}
|
||
}
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc'
|
||
}
|
||
});
|
||
|
||
return pendingRequests;
|
||
} catch (error: any) {
|
||
console.error('Error fetching pending share requests:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Respond to share request (accept/decline)
|
||
export async function respondToShareRequest(shareId: string, action: 'accept' | 'decline') {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const share = await prisma.noteShare.findUnique({
|
||
where: { id: shareId },
|
||
include: {
|
||
note: true,
|
||
sharer: true
|
||
}
|
||
});
|
||
|
||
if (!share) {
|
||
throw new Error('Share request not found');
|
||
}
|
||
|
||
// Verify this share belongs to current user
|
||
if (share.userId !== session.user.id) {
|
||
throw new Error('Unauthorized');
|
||
}
|
||
|
||
// Update share status
|
||
const updatedShare = await prisma.noteShare.update({
|
||
where: { id: shareId },
|
||
data: {
|
||
status: action === 'accept' ? 'accepted' : 'declined',
|
||
respondedAt: new Date()
|
||
},
|
||
include: {
|
||
note: {
|
||
select: {
|
||
title: true
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Revalidate all relevant cache tags
|
||
revalidatePath('/');
|
||
|
||
return { success: true, share: updatedShare };
|
||
} catch (error: any) {
|
||
console.error('Error responding to share request:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Get all accepted shares for current user (notes shared with me)
|
||
export async function getAcceptedSharedNotes() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const acceptedShares = await prisma.noteShare.findMany({
|
||
where: {
|
||
userId: session.user.id,
|
||
status: 'accepted'
|
||
},
|
||
include: {
|
||
note: true
|
||
},
|
||
orderBy: {
|
||
createdAt: 'desc'
|
||
}
|
||
});
|
||
|
||
return acceptedShares.map(share => share.note);
|
||
} catch (error: any) {
|
||
console.error('Error fetching accepted shared notes:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Remove share from my view (hide the note)
|
||
export async function removeSharedNoteFromView(shareId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
const share = await prisma.noteShare.findUnique({
|
||
where: { id: shareId }
|
||
});
|
||
|
||
if (!share) {
|
||
throw new Error('Share not found');
|
||
}
|
||
|
||
// Verify this share belongs to current user
|
||
if (share.userId !== session.user.id) {
|
||
throw new Error('Unauthorized');
|
||
}
|
||
|
||
// Update status to 'removed' (note is hidden from user's view)
|
||
await prisma.noteShare.update({
|
||
where: { id: shareId },
|
||
data: {
|
||
status: 'removed'
|
||
}
|
||
});
|
||
|
||
revalidatePath('/');
|
||
return { success: true };
|
||
} catch (error: any) {
|
||
console.error('Error removing shared note from view:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Get notification count for pending shares
|
||
export async function getPendingShareCount() {
|
||
const session = await auth();
|
||
if (!session?.user?.id) return 0;
|
||
|
||
try {
|
||
const count = await prisma.noteShare.count({
|
||
where: {
|
||
userId: session.user.id,
|
||
status: 'pending'
|
||
}
|
||
});
|
||
|
||
return count;
|
||
} catch (error) {
|
||
console.error('Error getting pending share count:', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Leave a shared note (remove from my list)
|
||
export async function leaveSharedNote(noteId: string) {
|
||
const session = await auth();
|
||
if (!session?.user?.id) throw new Error('Unauthorized');
|
||
|
||
try {
|
||
// Find the NoteShare record for this user and note
|
||
const share = await prisma.noteShare.findUnique({
|
||
where: {
|
||
noteId_userId: {
|
||
noteId,
|
||
userId: session.user.id
|
||
}
|
||
}
|
||
});
|
||
|
||
if (!share) {
|
||
throw new Error('Share not found');
|
||
}
|
||
|
||
// Verify this share belongs to current user
|
||
if (share.userId !== session.user.id) {
|
||
throw new Error('Unauthorized');
|
||
}
|
||
|
||
// Update status to 'removed' (note is hidden from user's view)
|
||
await prisma.noteShare.update({
|
||
where: { id: share.id },
|
||
data: {
|
||
status: 'removed'
|
||
}
|
||
});
|
||
|
||
revalidatePath('/');
|
||
return { success: true };
|
||
} catch (error: any) {
|
||
console.error('Error leaving shared note:', error);
|
||
throw error;
|
||
}
|
||
}
|