fix: label management - transaction safety, deletion sync, error handling
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 42s
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:
@@ -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 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user