Audit Logging (story 4-6): - Nouveau modèle AuditLog (userId, action, resource, metadata, ip, createdAt) - Migration 20260529143000_add_audit_log appliquée - lib/audit-log.ts : logAuditEvent (fire-and-forget) + logAuditEventAsync + getClientIp - auth.ts : LOG LOGIN / LOGOUT / USER_CREATED sur chaque event NextAuth - /api/chat : log AI_REQUEST avec tokens + byok flag dans onFinish - /api/agents/run-for-note : log AI_REQUEST avec featureKey + noteId Zero-data-retention (story 4-5): - OpenAI provider : header OpenAI-No-Training: 1 - Anthropic provider : header Anthropic-No-Train: 1 - DeepSeek provider : header X-No-Train: 1 sprint-status: 4-5 et 4-6 → done Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
193 lines
6.9 KiB
TypeScript
193 lines
6.9 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
|
import { auth } from '@/auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { checkEntitlementOrThrow, QuotaExceededError, incrementUsageAsync } from '@/lib/entitlements'
|
|
import { logAuditEvent, getClientIp } from '@/lib/audit-log'
|
|
|
|
type GenerateType = 'slide-generator' | 'excalidraw-generator'
|
|
|
|
const TYPE_DEFAULTS: Record<GenerateType, {
|
|
role: string
|
|
tools: string[]
|
|
maxSteps: number
|
|
}> = {
|
|
'slide-generator': {
|
|
role: 'Génère une présentation professionnelle à partir du contenu de la note fournie. Appelle generate_slides avec un objet JSON structuré {title, theme, slides:[...]}.',
|
|
tools: ['note_search', 'note_read', 'generate_slides'],
|
|
maxSteps: 6,
|
|
},
|
|
'excalidraw-generator': {
|
|
role: 'Génère un diagramme Excalidraw clair et professionnel à partir du contenu de la note fournie.',
|
|
tools: ['note_search', 'note_read', 'generate_excalidraw'],
|
|
maxSteps: 6,
|
|
},
|
|
}
|
|
|
|
// ─── POST : kick off generation (fire-and-forget) ──────────────────────────
|
|
export async function POST(req: NextRequest) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
|
}
|
|
const userId = session.user.id
|
|
|
|
const body = await req.json()
|
|
const { noteId, type, theme, style, language, template } = body as {
|
|
noteId: string
|
|
type: GenerateType
|
|
theme?: string
|
|
style?: string
|
|
language?: string
|
|
template?: string
|
|
}
|
|
|
|
if (!noteId || !type || !TYPE_DEFAULTS[type]) {
|
|
return NextResponse.json({ error: 'Paramètres invalides' }, { status: 400 })
|
|
}
|
|
|
|
// Quota check — feature key depends on generation type
|
|
const featureKey = type === 'slide-generator' ? 'slide_generate' : 'excalidraw_generate'
|
|
try {
|
|
await checkEntitlementOrThrow(userId, featureKey)
|
|
} catch (e) {
|
|
if (e instanceof QuotaExceededError) {
|
|
return NextResponse.json({ error: e.message }, { status: 402 })
|
|
}
|
|
throw e
|
|
}
|
|
|
|
const note = await prisma.note.findFirst({
|
|
where: { id: noteId, userId },
|
|
select: { id: true, title: true, notebookId: true },
|
|
})
|
|
if (!note) {
|
|
return NextResponse.json({ error: 'Note introuvable' }, { status: 404 })
|
|
}
|
|
|
|
const defaults = TYPE_DEFAULTS[type]
|
|
const isEn = language === 'English'
|
|
|
|
let role = defaults.role
|
|
if (isEn) {
|
|
if (type === 'slide-generator') {
|
|
const recipeHint = (theme && theme !== 'auto') ? ` Use theme:"${theme}".` : ''
|
|
role = `Generate a professional presentation from the provided note content.${recipeHint} Call generate_slides with structured JSON {title, theme, slides:[...]}.`
|
|
} else {
|
|
role = 'Generate a clear and professional Excalidraw diagram from the provided note content.'
|
|
}
|
|
} else if (type === 'slide-generator') {
|
|
const recipeHint = (theme && theme !== 'auto') ? ` Utilise le thème "${theme}".` : ''
|
|
role = `Génère une présentation professionnelle à partir du contenu de la note fournie.${recipeHint} Appelle generate_slides avec le JSON structuré {title, theme, slides:[...]}.`
|
|
}
|
|
|
|
const agentName = type === 'slide-generator'
|
|
? `${isEn ? 'Slides' : 'Présentation'} — ${(note.title || 'Note').substring(0, 40)}`
|
|
: `${isEn ? 'Diagram' : 'Diagramme'} — ${(note.title || 'Note').substring(0, 40)}`
|
|
|
|
const agent = await prisma.agent.create({
|
|
data: {
|
|
name: agentName,
|
|
type,
|
|
role,
|
|
description: (type === 'slide-generator' && template && template !== 'auto') ? `template:${template}` : undefined,
|
|
tools: JSON.stringify(defaults.tools),
|
|
maxSteps: defaults.maxSteps,
|
|
frequency: 'one-shot',
|
|
isEnabled: true,
|
|
sourceNoteIds: JSON.stringify([noteId]),
|
|
targetNotebookId: note.notebookId ?? undefined,
|
|
slideTheme: theme ?? 'keynote',
|
|
slideStyle: style ?? 'soft',
|
|
userId,
|
|
},
|
|
})
|
|
|
|
// ── Fire and forget — do NOT await so the HTTP response returns immediately ──
|
|
// In Node.js / Docker self-hosted, the process keeps running after response.
|
|
import('@/lib/ai/services/agent-executor.service')
|
|
.then(({ executeAgent }) => executeAgent(agent.id, userId))
|
|
.catch(err => console.error('[run-for-note] Background agent error:', err))
|
|
|
|
logAuditEvent({
|
|
userId,
|
|
action: 'AI_REQUEST',
|
|
resource: featureKey,
|
|
metadata: { agentId: agent.id, noteId, featureKey },
|
|
ip: getClientIp(req),
|
|
})
|
|
|
|
return NextResponse.json({ success: true, agentId: agent.id, status: 'running' })
|
|
}
|
|
|
|
// ─── GET : poll current agent status ──────────────────────────────────────
|
|
export async function GET(req: NextRequest) {
|
|
const session = await auth()
|
|
if (!session?.user?.id) {
|
|
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 })
|
|
}
|
|
const userId = session.user.id
|
|
|
|
const agentId = req.nextUrl.searchParams.get('agentId')
|
|
if (!agentId) {
|
|
return NextResponse.json({ error: 'agentId manquant' }, { status: 400 })
|
|
}
|
|
|
|
// Verify ownership
|
|
const agent = await prisma.agent.findFirst({
|
|
where: { id: agentId, userId },
|
|
select: { id: true },
|
|
})
|
|
if (!agent) {
|
|
return NextResponse.json({ error: 'Agent introuvable' }, { status: 404 })
|
|
}
|
|
|
|
// Return latest action for this agent
|
|
const action = await prisma.agentAction.findFirst({
|
|
where: { agentId },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true, status: true, result: true, log: true, createdAt: true },
|
|
})
|
|
|
|
if (!action) {
|
|
// Action not created yet (race condition in first poll) — still running
|
|
return NextResponse.json({ status: 'running' })
|
|
}
|
|
|
|
// Detect stale running actions (killed by process restart or hot-reload)
|
|
const GENERATION_TIMEOUT_MS = 8 * 60 * 1000 // 8 minutes
|
|
if (action.status === 'running') {
|
|
const ageMs = Date.now() - new Date(action.createdAt).getTime()
|
|
if (ageMs > GENERATION_TIMEOUT_MS) {
|
|
await prisma.agentAction.update({
|
|
where: { id: action.id },
|
|
data: { status: 'failure', log: 'Generation timed out (process restart)' },
|
|
}).catch(() => { /* best-effort */ })
|
|
return NextResponse.json({ status: 'failure', actionId: action.id, error: 'La génération a expiré. Veuillez réessayer.' })
|
|
}
|
|
}
|
|
|
|
// If success, find canvasId from the related canvas (result stores canvas id)
|
|
let canvasId: string | null = null
|
|
let noteId: string | null = null
|
|
if (action.status === 'success' && action.result) {
|
|
// result field: the executor stores canvas.id or note.id
|
|
const canvas = await prisma.canvas.findFirst({
|
|
where: { id: action.result },
|
|
select: { id: true },
|
|
})
|
|
if (canvas) {
|
|
canvasId = canvas.id
|
|
} else {
|
|
noteId = action.result
|
|
}
|
|
}
|
|
|
|
return NextResponse.json({
|
|
status: action.status, // 'running' | 'success' | 'failure'
|
|
actionId: action.id,
|
|
canvasId,
|
|
noteId,
|
|
error: action.status === 'failure' ? (action.log || 'Erreur inconnue') : undefined,
|
|
})
|
|
}
|