feat: publication IA (magazine/brief/essay) + fixes critique
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m22s
CI / Deploy production (on server) (push) Has been skipped

Publication IA:
- 4 templates (magazine, brief, essay, simple) avec CSS riche
- Rewrite IA (article/exercises/tutorial/reference/mixed)
- Modération avec timeout 12s + fallback safe
- Quotas publish_enhance par tier (basic=2, pro=15, business=100)
- Détection contenu stale (hash)
- Migration DB publishedContent/publishedTemplate/publishedSourceHash

Fixes:
- cheerio v1.2: Element -> AnyNode (domhandler), decodeEntities cast
- _isShared ajouté au type Note (champ virtuel serveur)
- callout colors PDF export: extraction fonction pure testable
- admin/published: guard note.userId null
- Cmd+S fonctionne en mode dialog (pas seulement fullPage)

i18n:
- 23 clés publish* traduites dans les 15 locales
- Extension Web Clipper: 13 locales mise à jour

Tests:
- callout-colors.test.ts (6 tests)
- note-visible-in-view.test.ts (5 tests)
- entitlements.test.ts + byok-entitlements.test.ts: mock usageLog + unstubAllEnvs
- 199/199 tests passent

Tracker: user-stories.md sync avec sprint-status.yaml
This commit is contained in:
Antigravity
2026-06-28 07:32:57 +00:00
parent 902fe95a69
commit 96e7902f01
169 changed files with 5382 additions and 1527 deletions

View File

@@ -1,7 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { contentModerationService } from '@/lib/ai/services/content-moderation.service'
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<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
@@ -14,11 +38,70 @@ function generateSlug(title: string): string {
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
}
}
export async function POST(request: NextRequest) {
const session = await auth()
if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { noteId, action } = await request.json()
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({
@@ -28,14 +111,96 @@ export async function POST(request: NextRequest) {
if (!note) return NextResponse.json({ error: 'Not found' }, { status: 404 })
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' }
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',
@@ -43,46 +208,36 @@ export async function POST(request: NextRequest) {
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(() => {})
}
await notifyFlaggedAdmins(note.id, note.title || '', moderation.reason)
}
let slug = note.publicSlug
if (!slug) {
slug = generateSlug(note.title || 'note')
const existing = await prisma.note.findUnique({ where: { publicSlug: slug } })
if (existing && existing.id !== noteId) slug = `${slug}-${Date.now().toString(36)}`
}
await prisma.note.update({
where: { id: noteId },
data: { isPublic: true, publicSlug: slug, publishedAt: new Date() },
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 prisma.note.update({
where: { id: noteId },
data: { isPublic: false, publicSlug: null, publishedAt: null },
await updateNotePublishState(noteId, {
isPublic: false,
publicSlug: null,
publishedAt: null,
publishedContent: null,
publishedTemplate: null,
publishedSourceHash: null,
})
return NextResponse.json({ success: true })
}