## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1234 lines
36 KiB
TypeScript
1234 lines
36 KiB
TypeScript
'use server'
|
|
|
|
import { revalidatePath } from 'next/cache'
|
|
import prisma from '@/lib/prisma'
|
|
import { Note, CheckItem } from '@/lib/types'
|
|
import { auth } from '@/auth'
|
|
import { getAIProvider } from '@/lib/ai/factory'
|
|
import { cosineSimilarity, validateEmbedding, calculateRRFK, detectQueryType, getSearchWeights } from '@/lib/utils'
|
|
import { getSystemConfig, getConfigNumber, SEARCH_DEFAULTS } from '@/lib/config'
|
|
|
|
// Helper function to parse JSON strings from database
|
|
function parseNote(dbNote: any): Note {
|
|
// Parse embedding
|
|
const embedding = dbNote.embedding ? JSON.parse(dbNote.embedding) : null
|
|
|
|
// Validate embedding if present
|
|
if (embedding && Array.isArray(embedding)) {
|
|
const validation = validateEmbedding(embedding)
|
|
if (!validation.valid) {
|
|
// Don't include invalid embedding in the returned note
|
|
return {
|
|
...dbNote,
|
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
|
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
|
embedding: null, // Exclude invalid embedding
|
|
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
|
size: dbNote.size || 'small',
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
...dbNote,
|
|
checkItems: dbNote.checkItems ? JSON.parse(dbNote.checkItems) : null,
|
|
labels: dbNote.labels ? JSON.parse(dbNote.labels) : null,
|
|
images: dbNote.images ? JSON.parse(dbNote.images) : null,
|
|
links: dbNote.links ? JSON.parse(dbNote.links) : null,
|
|
embedding,
|
|
sharedWith: dbNote.sharedWith ? JSON.parse(dbNote.sharedWith) : [],
|
|
size: dbNote.size || 'small',
|
|
}
|
|
}
|
|
|
|
// Helper to get hash color for labels (copied from utils)
|
|
function getHashColor(name: string): string {
|
|
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'pink', 'orange', 'gray']
|
|
let hash = 0
|
|
for (let i = 0; i < name.length; i++) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash)
|
|
}
|
|
return colors[Math.abs(hash) % colors.length]
|
|
}
|
|
|
|
// Comprehensive sync function for labels - ensures consistency between Note.labels and Label table
|
|
async function syncLabels(userId: string, noteLabels: string[] = []) {
|
|
try {
|
|
// Step 1: Create Label records for any labels in notes that don't exist in Label table
|
|
// Get all existing labels for this user to do case-insensitive check in JS
|
|
const existingLabels = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true }
|
|
})
|
|
|
|
// Create a map for case-insensitive lookup
|
|
const existingLabelMap = new Map<string, string>()
|
|
existingLabels.forEach(label => {
|
|
existingLabelMap.set(label.name.toLowerCase(), label.name)
|
|
})
|
|
|
|
for (const labelName of noteLabels) {
|
|
if (!labelName || labelName.trim() === '') continue
|
|
|
|
const trimmedLabel = labelName.trim()
|
|
const lowerLabel = trimmedLabel.toLowerCase()
|
|
|
|
// Check if label already exists (case-insensitive)
|
|
const existingName = existingLabelMap.get(lowerLabel)
|
|
|
|
// If label doesn't exist, create it
|
|
if (!existingName) {
|
|
try {
|
|
await prisma.label.create({
|
|
data: {
|
|
userId,
|
|
name: trimmedLabel,
|
|
color: getHashColor(trimmedLabel)
|
|
}
|
|
})
|
|
// Add to map to prevent duplicates in same batch
|
|
existingLabelMap.set(lowerLabel, trimmedLabel)
|
|
} catch (e: any) {
|
|
// Ignore unique constraint violations (race condition)
|
|
if (e.code !== 'P2002') {
|
|
console.error(`[SYNC] Failed to create label "${trimmedLabel}":`, e)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Get ALL labels currently used in ALL user's notes
|
|
const allNotes = await prisma.note.findMany({
|
|
where: { userId },
|
|
select: { labels: true }
|
|
})
|
|
|
|
const usedLabelsSet = new Set<string>()
|
|
allNotes.forEach(note => {
|
|
if (note.labels) {
|
|
try {
|
|
const parsedLabels: string[] = JSON.parse(note.labels)
|
|
if (Array.isArray(parsedLabels)) {
|
|
parsedLabels.forEach(l => {
|
|
if (l && l.trim()) {
|
|
usedLabelsSet.add(l.trim().toLowerCase())
|
|
}
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('[SYNC] Failed to parse labels:', e)
|
|
}
|
|
}
|
|
})
|
|
|
|
// Step 3: Delete orphan Label records (labels not in any note)
|
|
const allLabels = await prisma.label.findMany({
|
|
where: { userId }
|
|
})
|
|
|
|
for (const label of allLabels) {
|
|
if (!usedLabelsSet.has(label.name.toLowerCase())) {
|
|
try {
|
|
await prisma.label.delete({
|
|
where: { id: label.id }
|
|
})
|
|
} catch (e) {
|
|
console.error(`Failed to delete orphan label:`, e)
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Fatal error in syncLabels:', error)
|
|
}
|
|
}
|
|
|
|
// Get all notes (non-archived by default)
|
|
export async function getNotes(includeArchived = false) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
...(includeArchived ? {} : { isArchived: false }),
|
|
},
|
|
orderBy: [
|
|
{ isPinned: 'desc' },
|
|
{ order: 'asc' },
|
|
{ updatedAt: 'desc' }
|
|
]
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Get archived notes only
|
|
export async function getArchivedNotes() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: true
|
|
},
|
|
orderBy: { updatedAt: 'desc' }
|
|
})
|
|
|
|
return notes.map(parseNote)
|
|
} catch (error) {
|
|
console.error('Error fetching archived notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
// Search notes - SIMPLE AND EFFECTIVE
|
|
// Supports contextual search within notebook (IA5)
|
|
export async function searchNotes(query: string, useSemantic: boolean = false, notebookId?: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
try {
|
|
// If query empty, return all notes
|
|
if (!query || !query.trim()) {
|
|
return await getAllNotes();
|
|
}
|
|
|
|
// If semantic search is requested, use the full implementation
|
|
if (useSemantic) {
|
|
return await semanticSearch(query, session.user.id, notebookId); // NEW: Pass notebookId for contextual search (IA5)
|
|
}
|
|
|
|
// Get all notes
|
|
const allNotes = await prisma.note.findMany({
|
|
where: {
|
|
userId: session.user.id,
|
|
isArchived: false
|
|
}
|
|
});
|
|
|
|
const queryLower = query.toLowerCase().trim();
|
|
|
|
// SIMPLE FILTER: check if query is in title OR content OR labels
|
|
const filteredNotes = allNotes.filter(note => {
|
|
const title = (note.title || '').toLowerCase();
|
|
const content = note.content.toLowerCase();
|
|
const labels = note.labels ? JSON.parse(note.labels) : [];
|
|
|
|
// Check if query exists in title, content, or any label
|
|
return title.includes(queryLower) ||
|
|
content.includes(queryLower) ||
|
|
labels.some((label: string) => label.toLowerCase().includes(queryLower));
|
|
});
|
|
|
|
return filteredNotes.map(parseNote);
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Semantic search with AI embeddings - SIMPLE VERSION
|
|
// Supports contextual search within notebook (IA5)
|
|
async function semanticSearch(query: string, userId: string, notebookId?: string) {
|
|
const allNotes = await prisma.note.findMany({
|
|
where: {
|
|
userId: userId,
|
|
isArchived: false,
|
|
...(notebookId !== undefined ? { notebookId } : {}) // NEW: Filter by notebook (IA5)
|
|
}
|
|
});
|
|
|
|
const queryLower = query.toLowerCase().trim();
|
|
|
|
// Get query embedding
|
|
let queryEmbedding: number[] | null = null;
|
|
try {
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
queryEmbedding = await provider.getEmbeddings(query);
|
|
} catch (e) {
|
|
console.error('Failed to generate query embedding:', e);
|
|
// Fallback to simple keyword search
|
|
queryEmbedding = null;
|
|
}
|
|
|
|
// Filter notes: keyword match OR semantic match (threshold 30%)
|
|
const results = allNotes.map(note => {
|
|
const title = (note.title || '').toLowerCase();
|
|
const content = note.content.toLowerCase();
|
|
const labels = note.labels ? JSON.parse(note.labels) : [];
|
|
|
|
// Keyword match
|
|
const keywordMatch = title.includes(queryLower) ||
|
|
content.includes(queryLower) ||
|
|
labels.some((l: string) => l.toLowerCase().includes(queryLower));
|
|
|
|
// Semantic match (if embedding available)
|
|
let semanticMatch = false;
|
|
let similarity = 0;
|
|
if (queryEmbedding && note.embedding) {
|
|
similarity = cosineSimilarity(queryEmbedding, JSON.parse(note.embedding));
|
|
semanticMatch = similarity > 0.3; // 30% threshold - works well for related concepts
|
|
}
|
|
|
|
return {
|
|
note,
|
|
keywordMatch,
|
|
semanticMatch,
|
|
similarity
|
|
};
|
|
}).filter(r => r.keywordMatch || r.semanticMatch);
|
|
|
|
// Parse and add match info
|
|
return results.map(r => {
|
|
const parsed = parseNote(r.note);
|
|
|
|
// Determine match type
|
|
let matchType: 'exact' | 'related' | null = null;
|
|
if (r.semanticMatch) {
|
|
matchType = 'related';
|
|
} else if (r.keywordMatch) {
|
|
matchType = 'exact';
|
|
}
|
|
|
|
return {
|
|
...parsed,
|
|
matchType
|
|
};
|
|
});
|
|
}
|
|
|
|
// Create a new note
|
|
export async function createNote(data: {
|
|
title?: string
|
|
content: string
|
|
color?: string
|
|
type?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[]
|
|
labels?: string[]
|
|
images?: string[]
|
|
links?: any[]
|
|
isArchived?: boolean
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
size?: 'small' | 'medium' | 'large'
|
|
sharedWith?: string[]
|
|
autoGenerated?: boolean
|
|
notebookId?: string | undefined // Assign note to a notebook if provided
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
let embeddingString: string | null = null;
|
|
try {
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
const embedding = await provider.getEmbeddings(data.content);
|
|
if (embedding) embeddingString = JSON.stringify(embedding);
|
|
} catch (e) {
|
|
console.error('Embedding generation failed:', e);
|
|
}
|
|
|
|
const note = await prisma.note.create({
|
|
data: {
|
|
userId: session.user.id,
|
|
title: data.title || null,
|
|
content: data.content,
|
|
color: data.color || 'default',
|
|
type: data.type || 'text',
|
|
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
|
labels: data.labels ? JSON.stringify(data.labels) : null,
|
|
images: data.images ? JSON.stringify(data.images) : null,
|
|
links: data.links ? JSON.stringify(data.links) : null,
|
|
isArchived: data.isArchived || false,
|
|
reminder: data.reminder || null,
|
|
isMarkdown: data.isMarkdown || false,
|
|
size: data.size || 'small',
|
|
embedding: embeddingString,
|
|
sharedWith: data.sharedWith && data.sharedWith.length > 0 ? JSON.stringify(data.sharedWith) : null,
|
|
autoGenerated: data.autoGenerated || null,
|
|
notebookId: data.notebookId || null, // Assign note to notebook if provided
|
|
}
|
|
})
|
|
|
|
// Sync labels to ensure Label records exist
|
|
if (data.labels && data.labels.length > 0) {
|
|
await syncLabels(session.user.id, data.labels)
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error creating note:', error)
|
|
throw new Error('Failed to create note')
|
|
}
|
|
}
|
|
|
|
// Update a note
|
|
export async function updateNote(id: string, data: {
|
|
title?: string | null
|
|
content?: string
|
|
color?: string
|
|
isPinned?: boolean
|
|
isArchived?: boolean
|
|
type?: 'text' | 'checklist'
|
|
checkItems?: CheckItem[] | null
|
|
labels?: string[] | null
|
|
images?: string[] | null
|
|
links?: any[] | null
|
|
reminder?: Date | null
|
|
isMarkdown?: boolean
|
|
size?: 'small' | 'medium' | 'large'
|
|
autoGenerated?: boolean | null
|
|
}) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
const oldNote = await prisma.note.findUnique({
|
|
where: { id, userId: session.user.id },
|
|
select: { labels: true }
|
|
})
|
|
const oldLabels: string[] = oldNote?.labels ? JSON.parse(oldNote.labels) : []
|
|
|
|
const updateData: any = { ...data }
|
|
|
|
if (data.content !== undefined) {
|
|
try {
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
const embedding = await provider.getEmbeddings(data.content);
|
|
updateData.embedding = embedding ? JSON.stringify(embedding) : null;
|
|
} catch (e) {
|
|
console.error('Embedding regeneration failed:', e);
|
|
}
|
|
}
|
|
|
|
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
|
|
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
|
|
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
|
|
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
|
updateData.updatedAt = new Date()
|
|
|
|
const note = await prisma.note.update({
|
|
where: { id, userId: session.user.id },
|
|
data: updateData
|
|
})
|
|
|
|
// Sync labels to ensure consistency between Note.labels and Label table
|
|
// This handles both creating new Label records and cleaning up orphans
|
|
if (data.labels !== undefined) {
|
|
await syncLabels(session.user.id, data.labels || [])
|
|
}
|
|
|
|
// Don't revalidatePath here - it would close the note editor dialog!
|
|
// The dialog will close via the onClose callback after save completes
|
|
// The UI will update via the normal React state management
|
|
return parseNote(note)
|
|
} catch (error) {
|
|
console.error('Error updating note:', error)
|
|
throw error // Re-throw the REAL error, not a generic one
|
|
}
|
|
}
|
|
|
|
// Delete a note
|
|
export async function deleteNote(id: string) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
|
|
try {
|
|
await prisma.note.delete({ where: { id, userId: session.user.id } })
|
|
|
|
// Sync labels with empty array to trigger cleanup of any orphans
|
|
// The syncLabels function will scan all remaining notes and clean up unused labels
|
|
await syncLabels(session.user.id, [])
|
|
|
|
revalidatePath('/')
|
|
return { success: true }
|
|
} catch (error) {
|
|
console.error('Error deleting note:', error)
|
|
throw new Error('Failed to delete note')
|
|
}
|
|
}
|
|
|
|
// Toggle functions
|
|
export async function togglePin(id: string, isPinned: boolean) { return updateNote(id, { isPinned }) }
|
|
export async function toggleArchive(id: string, isArchived: boolean) { return updateNote(id, { isArchived }) }
|
|
export async function updateColor(id: string, color: string) { return updateNote(id, { color }) }
|
|
export async function updateLabels(id: string, labels: string[]) { return updateNote(id, { labels }) }
|
|
export async function removeFusedBadge(id: string) { return updateNote(id, { autoGenerated: null }) }
|
|
|
|
// Update note size with revalidation
|
|
export async function updateSize(id: string, size: 'small' | 'medium' | 'large') {
|
|
await updateNote(id, { size })
|
|
revalidatePath('/')
|
|
}
|
|
|
|
// 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
|
|
export async function cleanupAllOrphans() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
let createdCount = 0;
|
|
let deletedCount = 0;
|
|
let errors: any[] = [];
|
|
try {
|
|
// Step 1: Get all labels from notes
|
|
const allNotes = await prisma.note.findMany({ where: { userId }, select: { labels: true } })
|
|
const allNoteLabels = new Set<string>();
|
|
allNotes.forEach(note => {
|
|
if (note.labels) {
|
|
try {
|
|
const parsedLabels: string[] = JSON.parse(note.labels);
|
|
if (Array.isArray(parsedLabels)) {
|
|
parsedLabels.forEach(l => {
|
|
if (l && l.trim()) allNoteLabels.add(l.trim());
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error('[CLEANUP] Failed to parse labels:', e);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Step 2: Get existing labels for case-insensitive comparison
|
|
const existingLabels = await prisma.label.findMany({
|
|
where: { userId },
|
|
select: { id: true, name: true }
|
|
})
|
|
const existingLabelMap = new Map<string, string>()
|
|
existingLabels.forEach(label => {
|
|
existingLabelMap.set(label.name.toLowerCase(), label.name)
|
|
})
|
|
|
|
// Step 3: Create missing Label records
|
|
for (const labelName of allNoteLabels) {
|
|
const lowerLabel = labelName.toLowerCase();
|
|
const existingName = existingLabelMap.get(lowerLabel);
|
|
|
|
if (!existingName) {
|
|
try {
|
|
await prisma.label.create({
|
|
data: {
|
|
userId,
|
|
name: labelName,
|
|
color: getHashColor(labelName)
|
|
}
|
|
});
|
|
createdCount++;
|
|
existingLabelMap.set(lowerLabel, labelName);
|
|
} catch (e: any) {
|
|
console.error(`Failed to create label:`, e);
|
|
errors.push({ label: labelName, error: e.message, code: e.code });
|
|
// Continue with next label
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 4: Delete orphan Label records
|
|
const allDefinedLabels = await prisma.label.findMany({ where: { userId }, select: { id: true, name: true } })
|
|
const usedLabelsSet = new Set<string>();
|
|
allNotes.forEach(note => {
|
|
if (note.labels) {
|
|
try {
|
|
const parsedLabels: string[] = JSON.parse(note.labels);
|
|
if (Array.isArray(parsedLabels)) parsedLabels.forEach(l => usedLabelsSet.add(l.toLowerCase()));
|
|
} catch (e) {
|
|
console.error('Failed to parse labels for orphan check:', e);
|
|
}
|
|
}
|
|
});
|
|
const orphans = allDefinedLabels.filter(label => !usedLabelsSet.has(label.name.toLowerCase()));
|
|
for (const orphan of orphans) {
|
|
try {
|
|
await prisma.label.delete({ where: { id: orphan.id } });
|
|
deletedCount++;
|
|
} catch (e) {
|
|
console.error(`Failed to delete orphan:`, e);
|
|
}
|
|
}
|
|
|
|
revalidatePath('/')
|
|
return {
|
|
success: true,
|
|
created: createdCount,
|
|
deleted: deletedCount,
|
|
errors,
|
|
message: `Created ${createdCount} missing label records, deleted ${deletedCount} orphan labels${errors.length > 0 ? ` (${errors.length} errors)` : ''}`
|
|
}
|
|
} catch (error) {
|
|
console.error('[CLEANUP] Fatal error:', error);
|
|
throw new Error('Failed to cleanup database')
|
|
}
|
|
}
|
|
|
|
export async function syncAllEmbeddings() {
|
|
const session = await auth();
|
|
if (!session?.user?.id) throw new Error('Unauthorized');
|
|
const userId = session.user.id;
|
|
let updatedCount = 0;
|
|
try {
|
|
const notesToSync = await prisma.note.findMany({ where: { userId, embedding: null } })
|
|
const provider = getAIProvider(await getSystemConfig());
|
|
for (const note of notesToSync) {
|
|
if (!note.content) continue;
|
|
try {
|
|
const embedding = await provider.getEmbeddings(note.content);
|
|
if (embedding) {
|
|
await prisma.note.update({ where: { id: note.id }, data: { embedding: JSON.stringify(embedding) } })
|
|
updatedCount++;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
return { success: true, count: updatedCount }
|
|
} catch (error: any) {
|
|
throw new Error(`Failed to sync embeddings: ${error.message}`)
|
|
}
|
|
}
|
|
|
|
// Get all notes including those shared with the user
|
|
export async function getAllNotes(includeArchived = false) {
|
|
const session = await auth();
|
|
if (!session?.user?.id) return [];
|
|
|
|
const userId = session.user.id;
|
|
|
|
try {
|
|
// 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
|
|
}
|
|
})
|
|
|
|
// Filter out archived shared notes if needed
|
|
const sharedNotes = acceptedShares
|
|
.map(share => share.note)
|
|
.filter(note => includeArchived || !note.isArchived)
|
|
|
|
const allNotes = [...ownNotes.map(parseNote), ...sharedNotes.map(parseNote)]
|
|
|
|
return allNotes
|
|
} catch (error) {
|
|
console.error('Error fetching notes:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|