import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/auth' import prisma from '@/lib/prisma' import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service' import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service' import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements' import { hasUserAiConsent } from '@/lib/consent/server-consent' import { isPublishTemplateId } from '@/lib/publish/types' import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render' const MODERATION_TIMEOUT_MS = 12_000 async function moderateWithFallback(title: string, content: string): Promise { try { return await Promise.race([ contentModerationService.moderate(title, content), new Promise((resolve) => { setTimeout(() => resolve({ verdict: 'safe', categories: ['safe'], reason: 'Moderation timeout', }), MODERATION_TIMEOUT_MS) }), ]) } catch { return { verdict: 'safe', categories: ['safe'], reason: 'Moderation indisponible' } } } function generateSlug(title: string): string { const base = title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 60) || 'note' return `${base}-${Math.random().toString(36).slice(2, 8)}` } async function ensureSlug(noteId: string, title: string, existingSlug: string | null): Promise { let slug = existingSlug if (!slug) { slug = generateSlug(title || 'note') const existing = await prisma.note.findUnique({ where: { publicSlug: slug } }) if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}` } return slug } async function notifyFlaggedAdmins(noteId: string, title: string, reason: string) { 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 "${title}" a été publiée avec un contenu potentiellement sensible: ${reason}`, actionUrl: '/admin/published', relatedId: noteId, }, }).catch(() => {}) } } type PublishUpdateData = { isPublic: boolean publicSlug: string | null publishedAt: Date | null publishedContent?: string | null publishedTemplate?: string | null publishedSourceHash?: string | null } /** Tolerates stale Prisma client during dev (before server restart). */ async function updateNotePublishState(noteId: string, data: PublishUpdateData) { try { await prisma.note.update({ where: { id: noteId }, data }) } catch (err) { const msg = err instanceof Error ? err.message : String(err) if (msg.includes('publishedContent') || msg.includes('Unknown argument')) { const { publishedContent, publishedTemplate, publishedSourceHash, ...core } = data await prisma.note.update({ where: { id: noteId }, data: core }) return } throw err } } export async function POST(request: NextRequest) { const session = await auth() if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) const body = await request.json() const { noteId, action, mode, template, language, rewrite } = body as { noteId?: string action?: string mode?: 'simple' | 'ai' template?: string language?: string rewrite?: boolean } if (!noteId) return NextResponse.json({ error: 'noteId required' }, { status: 400 }) const note = await prisma.note.findFirst({ where: { id: noteId, userId: session.user.id }, select: { id: true, title: true, publicSlug: true, content: true }, }) if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 }) if (action === 'publish') { const publishMode = mode === 'ai' ? 'ai' : 'simple' if (publishMode === 'ai') { if (!(await hasUserAiConsent())) { return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 }) } if (!template || !isPublishTemplateId(template)) { return NextResponse.json({ error: 'Invalid template' }, { status: 400 }) } try { await reserveUsageOrThrow(session.user.id, 'publish_enhance') } catch (err) { if (err instanceof QuotaExceededError) { const isTierLocked = err.currentQuota === 0 return NextResponse.json( { error: isTierLocked ? 'feature_locked' : 'quota_exceeded', errorKey: isTierLocked ? 'ai.featureLocked' : 'ai.quotaExceeded', upgradeTier: err.upgradeTier, }, { status: 402 }, ) } throw err } let renderedHtml: string let textForModeration: string try { if (rewrite) { const spec = await publishEnhanceService.rewrite( note.title || '', note.content || '', template, language || 'fr', ) renderedHtml = renderRewrittenTemplate(spec, template, note.content || '') textForModeration = `${spec.summary}\n${spec.body.replace(/<[^>]+>/g, ' ')}` } else { const spec = await publishEnhanceService.enhance( note.title || '', note.content || '', template, language || 'fr', ) renderedHtml = renderPublishedTemplate(spec, template, note.content || '') textForModeration = [spec.summary, spec.pullQuote, spec.epigraph, ...(spec.keyPoints || [])].join('\n') } } catch (err) { console.error('[publish] AI generation failed:', err) return NextResponse.json({ error: 'ai_generation_failed' }, { status: 500 }) } const moderation = await moderateWithFallback(note.title || '', textForModeration) if (moderation.verdict === 'blocked') { return NextResponse.json({ error: 'blocked', reason: moderation.reason, categories: moderation.categories, }, { status: 403 }) } if (moderation.verdict === 'flagged') { await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason) } const slug = await ensureSlug(note.id, note.title || '', note.publicSlug) const sourceHash = computePublishedSourceHash(note.content || '') await updateNotePublishState(noteId, { isPublic: true, publicSlug: slug, publishedAt: new Date(), publishedContent: renderedHtml, publishedTemplate: template, publishedSourceHash: sourceHash, }) return NextResponse.json({ success: true, slug, mode: 'ai', template, moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined, }) } // Simple publish — contenu brut de la note const moderation = await moderateWithFallback(note.title || '', note.content || '') if (moderation.verdict === 'blocked') { return NextResponse.json({ error: 'blocked', reason: moderation.reason, categories: moderation.categories, }, { status: 403 }) } if (moderation.verdict === 'flagged') { await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason) } const slug = await ensureSlug(note.id, note.title || '', note.publicSlug) await updateNotePublishState(noteId, { isPublic: true, publicSlug: slug, publishedAt: new Date(), publishedContent: null, publishedTemplate: null, publishedSourceHash: null, }) return NextResponse.json({ success: true, slug, mode: 'simple', moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined, }) } if (action === 'unpublish') { await updateNotePublishState(noteId, { isPublic: false, publicSlug: null, publishedAt: null, publishedContent: null, publishedTemplate: null, publishedSourceHash: null, }) return NextResponse.json({ success: true }) } return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) }