/** * POST /api/comment * Soumettre un commentaire sur une fiche. * * Corps attendu : * { orga_id: number, contenu: string, auteur_pseudo?: string, auteur_email?: string } * * Flux : * 1. Rate limit Redis : ≤ 5 commentaires / IP / jour (Sonnet 3) * 2. Validation basique * 3. Filtre éthique Mistral Nemo (timeout 2s — fallback pending si timeout/erreur) * 4. INSERT NocoDB table avis * 5. Retourner { ok: true, status: 'approved' | 'pending' } */ import { checkRateLimit } from '~/server/utils/rateLimit' interface CommentBody { orga_id: number contenu: string auteur_pseudo?: string auteur_email?: string } interface MistralFilterResult { safe: boolean category: string | null reason: string | null } async function filtreEthique( contenu: string, mistralKey: string ): Promise { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 2000) try { const response: any = await $fetch('https://api.mistral.ai/v1/chat/completions', { method: 'POST', headers: { Authorization: `Bearer ${mistralKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'open-mistral-nemo', temperature: 0.0, max_tokens: 100, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: `Tu es un modèle de modération de contenu. Tu analyses des commentaires soumis sur une plateforme professionnelle française dédiée aux architectes. Tu dois détecter les contenus problématiques. CONTENUS À SIGNALER (safe=false) : - Propos racistes, antisémites, islamophobes, xénophobes - Propos sexistes, misogynes, homophobes, transphobes, LGBTQIA-phobes - Propos validistes (mépris des personnes handicapées) - Diffamation personnelle nominative (accusations graves sans fondement contre une personne identifiable) - Spam publicitaire explicite (lien commercial non sollicité + texte promotionnel) - Harcèlement ciblé, menaces explicites CONTENUS À AUTORISER (safe=true) : - Critiques d'organisations ou de services (même sévères), sans attaque personnelle - Partages d'expériences professionnelles négatives factuelles - Désaccords et débats professionnels - Signalement d'erreurs ou d'informations inexactes RÈGLES : 1. En cas de doute, favorise safe=true (la liberté d'expression professionnelle prime). 2. Le champ "category" est obligatoire si safe=false. 3. Le champ "reason" est une phrase courte en français (max 20 mots). 4. Retourne UNIQUEMENT un objet JSON valide. FORMAT DE SORTIE : { "safe": true | false, "category": "racisme" | "sexisme" | "homophobie" | "antisémitisme" | "lgbtqia-phobie" | "validisme" | "diffamation" | "spam" | "harcelement" | null, "reason": "string (max 20 mots)" | null }`, }, { role: 'user', content: `COMMENTAIRE À ANALYSER :\n\n"${contenu}"\n\nAnalyse ce commentaire et retourne le JSON de modération.`, }, ], }), // @ts-ignore — signal pas dans les types $fetch mais fonctionne en pratique signal: controller.signal, }) clearTimeout(timeout) const raw = response?.choices?.[0]?.message?.content ?? '{}' const parsed = JSON.parse(raw) return { safe: parsed.safe !== false, category: parsed.category ?? null, reason: parsed.reason ?? null, } } catch { clearTimeout(timeout) // Timeout ou erreur réseau → fallback pending return { safe: false, category: null, reason: 'timeout_ou_erreur_mistral' } } } export default defineEventHandler(async (event) => { const config = useRuntimeConfig() // Rate limit : 5 commentaires / IP / jour const ip = getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || event.node.req.socket?.remoteAddress || '0.0.0.0' const allowed = await checkRateLimit(ip, 'comment', 5) if (!allowed) { throw createError({ statusCode: 429, statusMessage: 'Trop de commentaires. Réessaie demain.', }) } const body = await readBody(event) // Validation basique if (!body?.orga_id || !body?.contenu) { throw createError({ statusCode: 400, message: 'Champs requis manquants : orga_id, contenu' }) } const contenu = body.contenu.trim() if (contenu.length < 10 || contenu.length > 500) { throw createError({ statusCode: 400, message: 'Le commentaire doit faire entre 10 et 500 caractères' }) } // Filtre éthique Mistral Nemo let published = false let safeCheck = 'pending' let safeReason: string | null = null if (config.mistralApiKey) { const result = await filtreEthique(contenu, config.mistralApiKey) published = result.safe safeCheck = result.safe ? 'safe' : (result.reason === 'timeout_ou_erreur_mistral' ? 'pending' : 'flagged') safeReason = result.reason } else { // Clé Mistral absente → fallback pending (TODO configurer MISTRAL_API_KEY) safeCheck = 'pending' safeReason = 'mistral_key_manquante' } // Insertion NocoDB const tableId = config.commentTableId const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records` const record = await $fetch(url, { method: 'POST', headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json', }, body: JSON.stringify({ orga_id: body.orga_id, contenu, auteur_pseudo: body.auteur_pseudo || null, auteur_email: body.auteur_email || null, published, safe_check: safeCheck, safe_reason: safeReason, submitted_at: new Date().toISOString(), }), }).catch((err: any) => { throw createError({ statusCode: 502, message: 'Erreur NocoDB lors de l\'insertion du commentaire' }) }) return { ok: true, status: published ? 'approved' : 'pending', message: published ? 'Commentaire publié. Merci pour ta contribution !' : 'Commentaire reçu et en cours de modération.', } })