feat: modération IA automatique à la publication
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 5m30s
CI / Deploy production (on server) (push) Failing after 0s

- contentModerationService branché dans /api/notes/publish
- blocked → 403, publication refusée, toast d'explication
- flagged → publié mais admins notifiés pour révision
- safe → publication normale
- PublishDialog gère les 3 cas (succès normal, flagged, blocked)
- i18n FR/EN
This commit is contained in:
Antigravity
2026-06-20 07:51:44 +00:00
parent 1774544385
commit 17594124b0
4 changed files with 55 additions and 3 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth' import { auth } from '@/auth'
import prisma from '@/lib/prisma' import prisma from '@/lib/prisma'
import { contentModerationService } from '@/lib/ai/services/content-moderation.service'
function generateSlug(title: string): string { function generateSlug(title: string): string {
const base = title const base = title
@@ -22,11 +23,44 @@ export async function POST(request: NextRequest) {
const note = await prisma.note.findFirst({ const note = await prisma.note.findFirst({
where: { id: noteId, userId: session.user.id }, where: { id: noteId, userId: session.user.id },
select: { id: true, title: true, publicSlug: true }, select: { id: true, title: true, publicSlug: true, content: true },
}) })
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
if (action === 'publish') { if (action === 'publish') {
// --- AI Moderation ---
let moderation
try {
moderation = await contentModerationService.moderate(note.title || '', note.content || '')
} catch {
moderation = { verdict: 'safe' as const, categories: ['safe'], reason: 'Moderation indisponible' }
}
if (moderation.verdict === 'blocked') {
return NextResponse.json({
error: 'blocked',
reason: moderation.reason,
categories: moderation.categories,
}, { status: 403 })
}
// flagged → publish but notify admins
if (moderation.verdict === 'flagged') {
const admins = await prisma.user.findMany({ where: { role: 'ADMIN' }, select: { id: true } })
for (const admin of admins) {
await prisma.notification.create({
data: {
userId: admin.id,
type: 'content_flagged',
title: 'Contenu sensible publié',
message: `La note "${note.title}" a été publiée avec un contenu potentiellement sensible: ${moderation.reason}`,
actionUrl: '/admin/published',
relatedId: note.id,
},
}).catch(() => {})
}
}
let slug = note.publicSlug let slug = note.publicSlug
if (!slug) { if (!slug) {
slug = generateSlug(note.title || 'note') slug = generateSlug(note.title || 'note')
@@ -37,7 +71,12 @@ export async function POST(request: NextRequest) {
where: { id: noteId }, where: { id: noteId },
data: { isPublic: true, publicSlug: slug, publishedAt: new Date() }, data: { isPublic: true, publicSlug: slug, publishedAt: new Date() },
}) })
return NextResponse.json({ success: true, slug })
return NextResponse.json({
success: true,
slug,
moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined,
})
} }
if (action === 'unpublish') { if (action === 'unpublish') {

View File

@@ -41,7 +41,18 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
if (res.ok && data.slug) { if (res.ok && data.slug) {
setIsPublic(true) setIsPublic(true)
setSlug(data.slug) setSlug(data.slug)
if (data.moderation === 'flagged') {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !', {
description: '⚠️ Un modérateur examinera le contenu sous peu.',
})
} else {
toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !') toast.success(t('richTextEditor.publishSuccess') || 'Note publiée !')
}
} else if (data.error === 'blocked') {
toast.error(t('richTextEditor.publishBlocked') || 'Publication refusée', {
description: data.reason || 'Le contenu ne respecte pas les règles de publication.',
duration: 6000,
})
} else { } else {
toast.error(data.error || 'Erreur') toast.error(data.error || 'Erreur')
} }

View File

@@ -2574,6 +2574,7 @@
"settingsPublishedEmpty": "No published notes yet.", "settingsPublishedEmpty": "No published notes yet.",
"unpublish": "Unpublish", "unpublish": "Unpublish",
"unpublishSuccess": "Note unpublished", "unpublishSuccess": "Note unpublished",
"publishBlocked": "Publication refused",
"slashSubPage": "Sub-page", "slashSubPage": "Sub-page",
"slashSubPageDesc": "Create a linked note inside this note", "slashSubPageDesc": "Create a linked note inside this note",
"exercisesLoading": "Generating exercises...", "exercisesLoading": "Generating exercises...",

View File

@@ -2578,6 +2578,7 @@
"settingsPublishedEmpty": "Aucune note publiée pour le moment.", "settingsPublishedEmpty": "Aucune note publiée pour le moment.",
"unpublish": "Dépublier", "unpublish": "Dépublier",
"unpublishSuccess": "Note dépubliée", "unpublishSuccess": "Note dépubliée",
"publishBlocked": "Publication refusée",
"slashSubPage": "Sous-page", "slashSubPage": "Sous-page",
"slashSubPageDesc": "Créer une note liée dans cette note", "slashSubPageDesc": "Créer une note liée dans cette note",
"exercisesLoading": "Génération des exercices...", "exercisesLoading": "Génération des exercices...",