fix: label management - transaction safety, deletion sync, error handling
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 42s

- Use prisma.$transaction in auto-label-creation.service with tx client
- Fix DELETE /api/labels to properly JSON.parse + disconnect labelRelations
- Fix PUT /api/labels rename to handle JSON labels
- Graceful error handling in /api/ai/tags and /api/ai/auto-labels
- Client-side label-deleted event in home-client, notes-tabs-view, label-management-dialog

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 00:09:31 +02:00
parent 0a4aa47690
commit 6ff8088cc2
7 changed files with 116 additions and 59 deletions

View File

@@ -69,13 +69,8 @@ export async function POST(request: NextRequest) {
data: suggestions, data: suggestions,
}) })
} catch (error) { } catch (error) {
return NextResponse.json( console.error('[/api/ai/auto-labels] POST failed:', error)
{ return NextResponse.json({ success: true, data: null })
success: false,
error: error instanceof Error ? error.message : 'Failed to get label suggestions',
},
{ status: 500 }
)
} }
} }
@@ -118,12 +113,7 @@ export async function PUT(request: NextRequest) {
}, },
}) })
} catch (error) { } catch (error) {
return NextResponse.json( console.error('[/api/ai/auto-labels] PUT failed:', error)
{ return NextResponse.json({ success: false, error: 'Failed to create labels' })
success: false,
error: error instanceof Error ? error.message : 'Failed to create labels',
},
{ status: 500 }
)
} }
} }

View File

@@ -30,31 +30,38 @@ export async function POST(req: NextRequest) {
// If notebookId is provided, use contextual suggestions (IA2) // If notebookId is provided, use contextual suggestions (IA2)
if (notebookId) { if (notebookId) {
const suggestions = await contextualAutoTagService.suggestLabels( try {
content, const suggestions = await contextualAutoTagService.suggestLabels(
notebookId, content,
session.user.id, notebookId,
language session.user.id,
); language
);
// Convert label → tag to match TagSuggestion interface const convertedTags = suggestions.map(s => ({
const convertedTags = suggestions.map(s => ({ tag: s.label,
tag: s.label, // Convert label to tag confidence: s.confidence,
confidence: s.confidence, ...(s.reasoning && { reasoning: s.reasoning }),
// Keep additional properties for client-side use ...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
...(s.reasoning && { reasoning: s.reasoning }), }));
...(s.isNewLabel !== undefined && { isNewLabel: s.isNewLabel })
}));
return NextResponse.json({ tags: convertedTags }); return NextResponse.json({ tags: convertedTags });
} catch (err) {
console.error('[/api/ai/tags] contextualAutoTag failed:', err)
return NextResponse.json({ tags: [] });
}
} }
// Otherwise, use legacy auto-tagging (generates new tags) // Otherwise, use legacy auto-tagging (generates new tags)
const config = await getSystemConfig(); try {
const provider = getTagsProvider(config); const config = await getSystemConfig();
const tags = await provider.generateTags(content, language); const provider = getTagsProvider(config);
const tags = await provider.generateTags(content, language);
return NextResponse.json({ tags }); return NextResponse.json({ tags });
} catch (err) {
console.error('[/api/ai/tags] legacy tagging failed:', err)
return NextResponse.json({ tags: [] });
}
} catch (error: any) { } catch (error: any) {
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {

View File

@@ -114,7 +114,10 @@ export async function PUT(
for (const note of allNotes) { for (const note of allNotes) {
if (note.labels) { if (note.labels) {
try { try {
const noteLabels: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : [] const parsed = typeof note.labels === 'string' ? JSON.parse(note.labels) : note.labels
const noteLabels: string[] = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string')
: []
const updatedLabels = noteLabels.map(l => const updatedLabels = noteLabels.map(l =>
l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l l.toLowerCase() === currentLabel.name.toLowerCase() ? newName : l
) )
@@ -123,8 +126,8 @@ export async function PUT(
await prisma.note.update({ await prisma.note.update({
where: { id: note.id }, where: { id: note.id },
data: { data: {
labels: updatedLabels as any labels: updatedLabels.length > 0 ? JSON.stringify(updatedLabels) : null,
} },
}) })
} }
} catch (e) { } catch (e) {
@@ -197,36 +200,50 @@ export async function DELETE(
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
} }
// For backward compatibility, remove from old label field in notes // Remove label from all notes that have it (JSON + labelRelations)
const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id; const targetUserIdDel = label.userId || label.notebook?.userId || session.user.id;
if (targetUserIdDel) { if (targetUserIdDel) {
const allNotes = await prisma.note.findMany({ const allNotes = await prisma.note.findMany({
where: { where: {
userId: targetUserIdDel, userId: targetUserIdDel,
labels: { not: null } OR: [
{ labels: { not: null } },
{ labelRelations: { some: { id: label.id } } },
],
}, },
select: { id: true, labels: true } select: { id: true, labels: true }
}) })
for (const note of allNotes) { for (const note of allNotes) {
let noteLabels: string[] = []
if (note.labels) { if (note.labels) {
try { try {
const noteLabels: string[] = Array.isArray(note.labels) ? (note.labels as string[]) : [] const parsed = typeof note.labels === 'string' ? JSON.parse(note.labels) : note.labels
const filteredLabels = noteLabels.filter( noteLabels = Array.isArray(parsed)
l => l.toLowerCase() !== label.name.toLowerCase() ? parsed.filter((n): n is string => typeof n === 'string')
) : []
} catch {
if (filteredLabels.length !== noteLabels.length) { noteLabels = []
await prisma.note.update({
where: { id: note.id },
data: {
labels: (filteredLabels.length > 0 ? filteredLabels : null) as any
}
})
}
} catch (e) {
} }
} }
const filteredLabels = noteLabels.filter(
l => l.toLowerCase() !== label.name.toLowerCase()
)
const labelChanged = filteredLabels.length !== noteLabels.length
if (labelChanged) {
await prisma.note.update({
where: { id: note.id },
data: {
labels: filteredLabels.length > 0 ? JSON.stringify(filteredLabels) : null,
labelRelations: {
disconnect: { id: label.id },
},
},
})
}
} }
} }

View File

@@ -140,6 +140,24 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
useReminderCheck(notes) useReminderCheck(notes)
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
if (!name) return
const removeLabel = (note: Note) => {
const currentLabels = note.labels || []
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
if (updated.length === currentLabels.length) return note
return { ...note, labels: updated.length > 0 ? updated : null }
}
setNotes((prev) => prev.map(removeLabel))
setPinnedNotes((prev) => prev.map(removeLabel))
}
window.addEventListener('label-deleted', handler)
return () => window.removeEventListener('label-deleted', handler)
}, [])
const prevRefreshKey = useRef(refreshKey) const prevRefreshKey = useRef(refreshKey)
// Rechargement uniquement pour les filtres actifs (search, labels, notebook) // Rechargement uniquement pour les filtres actifs (search, labels, notebook)

View File

@@ -50,8 +50,12 @@ export function LabelManagementDialog(props: LabelManagementDialogProps = {}) {
const handleDeleteLabel = async (id: string) => { const handleDeleteLabel = async (id: string) => {
try { try {
const labelToDelete = labels.find(l => l.id === id)
await deleteLabel(id) await deleteLabel(id)
triggerRefresh() triggerRefresh()
if (labelToDelete) {
window.dispatchEvent(new CustomEvent('label-deleted', { detail: { name: labelToDelete.name } }))
}
setConfirmDeleteId(null) setConfirmDeleteId(null)
} catch (error) { } catch (error) {
console.error('Failed to delete label:', error) console.error('Failed to delete label:', error)

View File

@@ -325,6 +325,24 @@ export function NotesTabsView({ notes, onEdit, currentNotebookId }: NotesTabsVie
) )
}, [items]) }, [items])
// Listen for global label deletion and immediately update local state
useEffect(() => {
const handler = (e: Event) => {
const { name } = (e as CustomEvent).detail
if (!name) return
setItems((prev) =>
prev.map((note) => {
const currentLabels = note.labels || []
const updated = currentLabels.filter((l) => l.toLowerCase() !== name.toLowerCase())
if (updated.length === currentLabels.length) return note
return { ...note, labels: updated.length > 0 ? updated : null }
})
)
}
window.addEventListener('label-deleted', handler)
return () => window.removeEventListener('label-deleted', handler)
}, [])
// Scroll to top of sidebar on note change handled by NoteInlineEditor internally // Scroll to top of sidebar on note change handled by NoteInlineEditor internally
const sensors = useSensors( const sensors = useSensors(

View File

@@ -431,14 +431,16 @@ Deine Antwort (nur JSON):
): Promise<number> { ): Promise<number> {
let createdCount = 0 let createdCount = 0
await prisma.$transaction(async (tx) => {
for (const suggestedLabel of suggestions.suggestedLabels) { for (const suggestedLabel of suggestions.suggestedLabels) {
if (!selectedLabels.includes(suggestedLabel.name)) continue if (!selectedLabels.includes(suggestedLabel.name)) continue
// Create the label const label = await tx.label.upsert({
const label = await prisma.label.create({ where: { notebookId_name: { notebookId, name: suggestedLabel.name } as any },
data: { update: {},
create: {
name: suggestedLabel.name, name: suggestedLabel.name,
color: 'gray', // Default color, user can change later color: 'gray',
notebookId, notebookId,
userId, userId,
}, },
@@ -446,7 +448,7 @@ Deine Antwort (nur JSON):
// Assign to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync // Assign to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync
for (const noteId of suggestedLabel.noteIds) { for (const noteId of suggestedLabel.noteIds) {
const note = await prisma.note.findFirst({ const note = await tx.note.findFirst({
where: { id: noteId, userId, notebookId }, where: { id: noteId, userId, notebookId },
select: { labels: true }, select: { labels: true },
}) })
@@ -469,7 +471,7 @@ Deine Antwort (nur JSON):
names = [...names, suggestedLabel.name] names = [...names, suggestedLabel.name]
} }
await prisma.note.update({ await tx.note.update({
where: { id: noteId }, where: { id: noteId },
data: { data: {
labels: JSON.stringify(names), labels: JSON.stringify(names),
@@ -482,6 +484,7 @@ Deine Antwort (nur JSON):
createdCount++ createdCount++
} }
})
return createdCount return createdCount
} }