Files
Momento/memento-note/app/api/notes/publish/route.ts
Antigravity 80ccc1f6de
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00

387 lines
13 KiB
TypeScript

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<ModerationResult> {
try {
return await Promise.race([
contentModerationService.moderate(title, content),
new Promise<ModerationResult>((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<string> {
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 })
}