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, aiConsentForbiddenResponse } from '@/lib/consent/server-consent' import { isPublishTemplateId, isInteractivePageTemplate } from '@/lib/publish/types' import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render' import { validateInteractivePage, type PageSpecV1, type PageValidationResult } from '@/lib/interactive-page' import { getSystemConfig } from '@/lib/config' import { getSlidesProvider } from '@/lib/ai/factory' import { generateInteractivePageFromContent } from '@/lib/ai/services/interactive-page-generate.service' 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 } } /** All human-facing text of a PageSpecV1 — fed to moderation. */ function collectPageText(page: PageSpecV1): string[] { const out: string[] = [ page.hero.kicker, page.hero.title, page.hero.subtitle ?? '', page.hero.meta ?? '', page.footer ?? '', ] if (page.overview) { out.push(page.overview.lead) for (const c of page.overview.cards) out.push(c.badge, c.title, c.body) } for (const section of page.sections) { out.push(section.title) for (const b of section.blocks) { if (b.type === 'prose') out.push(b.md) else if (b.type === 'formula') out.push(b.caption ?? '') else if (b.type === 'callout') out.push(b.title, b.md) else if (b.type === 'demo') { out.push(b.caption ?? '', b.demo.disclaimer ?? '') for (const act of b.demo.acts) { out.push(act.title) for (const st of act.steps) out.push(st.speak) } for (const panel of b.demo.scene.panels) { if (panel.type === 'svg-scene') { for (const n of panel.payload.nodes) out.push(n.label ?? '') } } } else if (b.type === 'chart') { out.push(b.caption ?? '') for (const s of b.payload.series) out.push(s.label ?? '') } else if (b.type === 'stats') { for (const it of b.items) out.push(it.value, it.label) } else if (b.type === 'table') { out.push(b.caption ?? '', ...b.columns, ...b.rows.flat()) } else if (b.type === 'image') { out.push(b.alt, b.caption ?? '') } else if (b.type === 'sim') { out.push(b.caption ?? '', b.sim.title ?? '', b.sim.disclaimer ?? '') } } } return out.filter((s) => s && s.trim()) } 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().catch(() => null) if (!body || typeof body !== 'object') { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } const { noteId, action, mode, template, language, rewrite, pageSpec } = body as { noteId?: string action?: string mode?: 'simple' | 'ai' | 'interactive-page' template?: string language?: string rewrite?: boolean pageSpec?: unknown } 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') { // ── Interactive page (PageSpecV1 JSON snapshot) ───────────────────── if (mode === 'interactive-page' || isInteractivePageTemplate(template)) { if (!(await hasUserAiConsent())) { return aiConsentForbiddenResponse() } let validatedPage: PageValidationResult | null = null if (pageSpec) { // Client-provided page (from the preview dialog): must validate as-is. // Never silently substitute a different page than the one previewed. const checked = validateInteractivePage(pageSpec) if (!checked.ok) { return NextResponse.json( { error: 'invalid_page_spec', issues: checked.issues.slice(0, 12) }, { status: 422 } ) } validatedPage = checked } if (!validatedPage) { // Deterministic generate (no LLM quota) const config = await getSystemConfig() const provider = getSlidesProvider(config) const generated = await generateInteractivePageFromContent({ content: note.content || '', lang: language || 'fr', provider, }) if (!generated.ok) { return NextResponse.json( { error: generated.error || 'interactive_page_generation_failed', reason: generated.reason, issues: generated.issues?.slice(0, 12), }, { status: 422 } ) } validatedPage = { ok: true as const, page: generated.page } } // Moderate ALL human-facing text of the page (prose, callouts, demo // narration, tables, captions) — not just titles. const textForModeration = collectPageText(validatedPage.page).join('\n') 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: JSON.stringify(validatedPage.page), publishedTemplate: 'interactive-page', publishedSourceHash: sourceHash, }) return NextResponse.json({ success: true, slug, mode: 'interactive-page', template: 'interactive-page', moderation: moderation.verdict === 'flagged' ? 'flagged' : undefined, }) } const publishMode = mode === 'ai' ? 'ai' : 'simple' if (publishMode === 'ai') { if (!(await hasUserAiConsent())) { return aiConsentForbiddenResponse() } if (!template || !isPublishTemplateId(template) || isInteractivePageTemplate(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 }) }