feat(aep): carte AEP — push Gitea 2026-04-28

This commit is contained in:
Jules Neny
2026-04-28 14:00:05 +02:00
commit 21c44d8193
86 changed files with 31855 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
/**
* GET /api/comment/[orgId]
* Retourne les commentaires publiés (published=true) pour une fiche.
* Triés par submitted_at ASC (chronologique).
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const orgId = getRouterParam(event, 'orgId')
if (!orgId || isNaN(Number(orgId))) {
throw createError({ statusCode: 400, message: 'Identifiant invalide' })
}
const tableId = config.commentTableId
const url = `${config.nocodbUrl}/api/v2/tables/${tableId}/records?where=(orga_id,eq,${orgId})~and(published,eq,true)&sort=submitted_at&limit=50`
const data: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => ({ list: [] }))
return { list: data?.list ?? [] }
})

View File

@@ -0,0 +1,183 @@
/**
* 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<MistralFilterResult> {
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<CommentBody>(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.',
}
})