fix: label system overhaul - sync dual storage, fix suggestions
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 53s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 53s
- Replace broken upsert (nbScope ?? '') with findFirst+create for null notebookId - Remove aggressive orphan cleanup that deleted notebook labels after save - Add syncNoteLabels() to update both Note.labels JSON and labelRelations - Fix createNote, updateNote, auto-labeling to use syncNoteLabels - Add fallback: suggestFromExistingLabels → suggestNewLabels if empty - Lower confidence threshold 0.6→0.3, max suggestions 3→5 - Case-insensitive label matching in suggestions Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -99,85 +99,55 @@ function collectLabelNamesFromNote(note: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Label rows with Note.labels.
|
||||
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
|
||||
* Upsert Label rows and return their IDs.
|
||||
* No orphan cleanup — labels are only deleted via the label management dialog.
|
||||
*/
|
||||
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
|
||||
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> {
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
for (const name of trimmedNames) {
|
||||
const existing = await prisma.label.findFirst({
|
||||
where: { userId, name, notebookId: nbScope },
|
||||
})
|
||||
if (!existing) {
|
||||
await prisma.label.create({
|
||||
data: { 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 } }
|
||||
})
|
||||
}
|
||||
if (noteLabels.length === 0) return []
|
||||
const uniqueNames = [...new Set(noteLabels.map(n => n.trim().toLowerCase()).filter(Boolean))]
|
||||
return prisma.label.findMany({
|
||||
where: { userId, notebookId: nbScope, name: { in: uniqueNames } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Fatal error in syncLabels:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync both Note.labels (JSON) AND labelRelations for a single note. */
|
||||
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
|
||||
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
|
||||
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
|
||||
const labelIds = labelRows.map(l => l.id)
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null,
|
||||
labelRelations: { set: labelIds.map(id => ({ id })) },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 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()
|
||||
@@ -198,7 +168,7 @@ export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
await syncLabels(session.user.id, labels, newNotebookId)
|
||||
await syncNoteLabels(noteId, labels, newNotebookId, session.user.id)
|
||||
}
|
||||
|
||||
// Get all notes (non-archived by default)
|
||||
@@ -441,7 +411,7 @@ export async function createNote(data: {
|
||||
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,
|
||||
labels: null, // set by syncNoteLabels below
|
||||
images: data.images ? JSON.stringify(data.images) : null,
|
||||
links: data.links ? JSON.stringify(data.links) : null,
|
||||
isArchived: data.isArchived || false,
|
||||
@@ -454,9 +424,9 @@ export async function createNote(data: {
|
||||
}
|
||||
})
|
||||
|
||||
// Sync user-provided labels immediately (étiquettes rattachées au carnet de la note)
|
||||
// Sync labels (JSON + labelRelations + Label rows) in one call
|
||||
if (data.labels && data.labels.length > 0) {
|
||||
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
|
||||
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
|
||||
}
|
||||
|
||||
if (!data.skipRevalidation) {
|
||||
@@ -528,11 +498,22 @@ export async function createNote(data: {
|
||||
.map(s => s.label)
|
||||
|
||||
if (appliedLabels.length > 0) {
|
||||
await prisma.note.update({
|
||||
// Merge with existing labels
|
||||
const existing = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
data: { labels: JSON.stringify(appliedLabels) }
|
||||
select: { labels: true },
|
||||
})
|
||||
await syncLabels(userId, appliedLabels, notebookId ?? null)
|
||||
let existingNames: string[] = []
|
||||
if (existing?.labels) {
|
||||
try {
|
||||
const parsed = existing.labels as unknown
|
||||
existingNames = Array.isArray(parsed)
|
||||
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
|
||||
: []
|
||||
} catch { existingNames = [] }
|
||||
}
|
||||
const merged = [...new Set([...existingNames, ...appliedLabels])]
|
||||
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
|
||||
if (!data.skipRevalidation) {
|
||||
revalidatePath('/')
|
||||
}
|
||||
@@ -616,7 +597,8 @@ export async function updateNote(id: string, data: {
|
||||
}
|
||||
|
||||
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
|
||||
// labels handled by syncNoteLabels below
|
||||
delete updateData.labels
|
||||
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
|
||||
@@ -637,14 +619,14 @@ export async function updateNote(id: string, data: {
|
||||
data: updateData
|
||||
})
|
||||
|
||||
// Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
|
||||
// Sync labels (JSON + labelRelations + Label rows)
|
||||
const notebookMoved =
|
||||
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
||||
if (data.labels !== undefined || notebookMoved) {
|
||||
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
|
||||
const effectiveNotebookId =
|
||||
data.notebookId !== undefined ? data.notebookId : oldNotebookId
|
||||
await syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null)
|
||||
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
|
||||
}
|
||||
|
||||
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
||||
|
||||
@@ -55,10 +55,12 @@ export class ContextualAutoTagService {
|
||||
|
||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||
if (notebook.labels.length > 0) {
|
||||
return await this.suggestFromExistingLabels(noteContent, notebook, language)
|
||||
const existing = await this.suggestFromExistingLabels(noteContent, notebook, language)
|
||||
if (existing.length > 0) return existing
|
||||
// Fallback: no existing label matched → suggest new ones too
|
||||
}
|
||||
|
||||
// CASE 2: Notebook has NO labels → suggest NEW labels to create
|
||||
// CASE 2: No labels in notebook (or none matched) → suggest NEW labels
|
||||
return await this.suggestNewLabels(noteContent, notebook, language)
|
||||
}
|
||||
|
||||
@@ -126,20 +128,23 @@ export class ContextualAutoTagService {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter and map suggestions
|
||||
// Filter and map suggestions (case-insensitive)
|
||||
const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase())
|
||||
const suggestions = suggestionsArray
|
||||
.filter((s: any) => {
|
||||
// Must be in available labels
|
||||
return availableLabels.includes(s.label) && s.confidence > 0.6
|
||||
return s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.3
|
||||
})
|
||||
.map((s: any) => ({
|
||||
label: s.label,
|
||||
.map((s: any) => {
|
||||
const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label
|
||||
return {
|
||||
label: originalLabel,
|
||||
confidence: Math.round(s.confidence * 100),
|
||||
reasoning: s.reasoning || '',
|
||||
isNewLabel: false,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||
.slice(0, 3) // Max 3 suggestions
|
||||
.slice(0, 5)
|
||||
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
@@ -213,7 +218,7 @@ export class ContextualAutoTagService {
|
||||
// Filter and map suggestions
|
||||
const suggestions = suggestionsArray
|
||||
.filter((s: any) => {
|
||||
return s.label && s.label.length > 0 && s.confidence > 0.6
|
||||
return s.label && s.label.length > 0 && (s.confidence || 0) > 0.3
|
||||
})
|
||||
.map((s: any) => ({
|
||||
label: s.label,
|
||||
@@ -222,7 +227,7 @@ export class ContextualAutoTagService {
|
||||
isNewLabel: true, // Mark as new label suggestion
|
||||
}))
|
||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||
.slice(0, 3) // Max 3 suggestions
|
||||
.slice(0, 5)
|
||||
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user