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

331
server/api/chatbot.post.ts Normal file
View File

@@ -0,0 +1,331 @@
/**
* POST /api/chatbot
*
* Chatbot recherche sémantique — Mistral Small
* Spec : F §7 (endpoint), F §8 (rate limit), E-spec §6 (détails chatbot)
*
* Flow :
* 1. Rate limit : 10 req/IP/jour (JSON fichier, SHA-256)
* 2. Circuit breaker : budget 20€/mois
* 3. Fetch top-N fiches (keyword match sur nom+description+fonctions)
* 4. Appel Mistral Small avec contexte JSON compact
* 5. Parse JSON → { reponse_texte, fiches_recommandees }
* 6. Log stats_usage
*
* Réponse 200 : { reponse_texte, fiches_recommandees: [{ id, nom, explication }] }
* Réponse 429 : rate limit dépassé
* Réponse 503 : budget IA épuisé
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
// ── Types ──────────────────────────────────────────────────────────────────────
interface OrgRow {
Id: number
nom: string
description_enrichie?: string | null
description_user?: string | null
tags_fonction?: string | null
echelle?: string | null
localisation_ville?: string | null
}
interface FicheReco {
id: number
nom: string
explication: string
}
interface MistralResponse {
reponse_texte: string
fiches_recommandees: FicheReco[]
}
// ── System prompt Mistral Small ────────────────────────────────────────────────
// Construit depuis E-spec-frontend.md §Détails chatbot + §Prompt système
// (F-spec §3 concerne Mistral Nemo enrichissement — ne pas confondre)
const SYSTEM_PROMPT = `Tu es un assistant engagé au service de la transition écologique des pratiques architecturales. Tu accèdes à AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base de données collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
RÈGLES ABSOLUES :
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous.
2. Ne jamais inventer d'organisation absente du contexte.
3. Cite chaque organisation recommandée par son nom exact et son identifiant id.
4. Si le contexte ne contient aucune organisation pertinente, dis-le honnêtement.
5. Réponses concises par défaut (200 mots max). Si l'usager demande explicitement plus de détail, tu peux développer.
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
7. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
FORMAT DE SORTIE :
{
"reponse_texte": "Ta réponse en prose (max 200 mots), orientée vers les besoins de l'architecte",
"fiches_recommandees": [
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
]
}
CONTEXTE — Organisations disponibles dans la base NAV :
{{FICHES_JSON}}`
// ── Recherche par mots-clés ────────────────────────────────────────────────────
function scoreOrg(org: OrgRow, keywords: string[]): number {
if (keywords.length === 0) return 1
const haystack = [
org.nom,
org.description_enrichie,
org.description_user,
org.tags_fonction,
org.localisation_ville,
org.echelle,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return keywords.reduce((score, kw) => {
return score + (haystack.includes(kw) ? 1 : 0)
}, 0)
}
function extractKeywords(question: string): string[] {
return question
.toLowerCase()
.replace(/[^\w\sàâäéèêëîïôùûüç-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length >= 3)
.slice(0, 10)
}
// ── Fetch fiches depuis NocoDB ─────────────────────────────────────────────────
async function fetchApprovedOrgs(config: {
nocodbUrl: string
nocodbToken: string
orgTableId: string
}): Promise<OrgRow[]> {
const { nocodbUrl, nocodbToken, orgTableId } = config
const url = `${nocodbUrl}/api/v2/tables/${orgTableId}/records`
try {
const res = await $fetch<{ list: OrgRow[] }>(url, {
headers: { 'xc-token': nocodbToken },
query: {
where: '(moderation_status,eq,approved)',
limit: 200,
fields: 'Id,nom,description_enrichie,description_user,tags_fonction,echelle,localisation_ville',
},
})
return res?.list ?? []
} catch (e) {
console.error('[chatbot] Erreur fetch NocoDB:', (e as Error).message)
return []
}
}
// ── Log stats_usage ────────────────────────────────────────────────────────────
async function logUsage(params: {
nocodbUrl: string
nocodbToken: string
statsTableId: string
tokensIn: number
tokensOut: number
coutEur: number
}) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try {
await $fetch(logUrl, {
method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mistral-small-latest',
endpoint: 'chatbot',
tokens_in: tokensIn,
tokens_out: tokensOut,
cout_eur: coutEur,
timestamp: new Date().toISOString(),
orga_id: null,
}),
})
} catch (e) {
console.warn('[chatbot] Log stats_usage échoué (non bloquant):', (e as Error).message)
}
}
// ── Handler principal ──────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
// 1. IP (proxy-aware)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit : 10 req/IP/jour (JSON + SHA-256)
const allowed = checkRateLimitJson(ip, 'chatbot', 10)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 10 questions par jour atteinte.',
})
}
// 3. Lire le body
const body = await readBody(event)
const question: string = (body?.question ?? '').trim()
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
if (!question || question.length < 3) {
throw createError({
statusCode: 400,
statusMessage: 'Question trop courte.',
})
}
// 4. Circuit breaker budget
const statsTableId = process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc'
const budget = await checkBudget({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
})
if (budget.blocked) {
throw createError({
statusCode: 503,
statusMessage: 'Budget IA mensuel épuisé — réouverture le 1er du mois prochain.',
})
}
// 5. Fetch fiches et scoring par mots-clés
const allOrgs = await fetchApprovedOrgs({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
orgTableId: config.orgTableId as string,
})
const keywords = extractKeywords(question)
// Filtrage optionnel par taxonomie si filtres fournis
let filtered = allOrgs
if (filters.fonction) {
filtered = filtered.filter((o) =>
(o.tags_fonction ?? '').toLowerCase().includes(filters.fonction!.toLowerCase()),
)
}
if (filters.echelle) {
filtered = filtered.filter((o) =>
(o.echelle ?? '').toLowerCase() === filters.echelle!.toLowerCase(),
)
}
// Score + top 20
const scored = filtered
.map((o) => ({ org: o, score: scoreOrg(o, keywords) }))
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map((x) => x.org)
// Contexte JSON compact pour le prompt
const fichesContext = scored.map((o) => ({
id: o.Id,
nom: o.nom,
fonctions: o.tags_fonction ?? '',
echelle: o.echelle ?? '',
description: (o.description_enrichie ?? o.description_user ?? '').slice(0, 200),
ville: o.localisation_ville ?? '',
}))
const systemPrompt = SYSTEM_PROMPT.replace(
'{{FICHES_JSON}}',
JSON.stringify(fichesContext, null, 0),
)
// 6. Appel Mistral Small
const mistralApiKey = config.mistralApiKey as string
if (!mistralApiKey) {
throw createError({
statusCode: 500,
statusMessage: 'Clé API Mistral manquante.',
})
}
let mistralRaw: string
let tokensIn = 0
let tokensOut = 0
try {
const mistralRes = await $fetch<{
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${mistralApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'mistral-small-latest',
temperature: 0.3,
max_tokens: 600,
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
}),
})
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0
} catch (e: any) {
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
})
}
// 7. Parse JSON
let parsed: MistralResponse
try {
const raw = JSON.parse(mistralRaw)
parsed = {
reponse_texte: raw.reponse_texte ?? 'Je n\'ai pas pu analyser ta demande.',
fiches_recommandees: (raw.fiches_recommandees ?? []).map((f: any) => {
const org = scored.find((o) => o.Id === f.id)
return {
id: f.id,
nom: org?.nom ?? f.nom ?? `Fiche #${f.id}`,
explication: f.explication ?? '',
}
}),
}
} catch {
parsed = {
reponse_texte: 'Je n\'ai pas pu analyser ta demande correctement.',
fiches_recommandees: [],
}
}
// 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut)
logUsage({
nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string,
statsTableId,
tokensIn,
tokensOut,
coutEur,
})
return parsed
})

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.',
}
})

View File

@@ -0,0 +1,26 @@
/**
* GET /api/fiche/[id]
* Proxy NocoDB — retourne la fiche complète avec tous les champs V2.
* Utilisé par pages/fiche/[id].vue (SSR).
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const id = getRouterParam(event, 'id')
if (!id || isNaN(Number(id))) {
throw createError({ statusCode: 400, message: 'Identifiant invalide' })
}
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(Id,eq,${id})&limit=1`
const data: any = await $fetch(url, {
headers: { 'xc-token': config.nocodbToken },
}).catch(() => null)
const record = data?.list?.[0] ?? null
if (!record) {
throw createError({ statusCode: 404, message: 'Organisation non trouvée' })
}
return record
})

View File

@@ -0,0 +1,94 @@
/**
* POST /api/report-general
*
* Signalement général (bug, contenu inapproprié, suggestion)
*
* Body : { category: string, description: string, email?: string }
* Rate limit : 5/IP/jour
* Envoi vers jules@trans-former.fr via Resend API
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr'
const VALID_CATEGORIES = ['Une fiche', 'Le chatbot', 'La carte', 'Autre'] as const
export default defineEventHandler(async (event) => {
// 1. IP
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit 5/IP/jour
const allowed = checkRateLimitJson(ip, 'report-general', 5)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 5 signalements par jour atteinte.',
})
}
// 3. Lire le body
const body = await readBody(event)
const category: string = (body?.category ?? '').trim()
const description: string = (body?.description ?? '').trim()
const email: string = (body?.email ?? '').trim()
// 4. Validation
if (!VALID_CATEGORIES.includes(category as any)) {
throw createError({ statusCode: 400, statusMessage: 'Catégorie invalide.' })
}
if (!description || description.length < 5 || description.length > 500) {
throw createError({ statusCode: 400, statusMessage: 'Description requise (5-500 caractères).' })
}
if (email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(email)) {
throw createError({ statusCode: 400, statusMessage: 'Email invalide.' })
}
}
// 5. Envoi via Resend
const resendApiKey = process.env.RESEND_API_KEY
if (!resendApiKey) {
console.error('[report-general] RESEND_API_KEY manquante')
throw createError({ statusCode: 500, statusMessage: 'Configuration email manquante.' })
}
const submittedAt = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
try {
await $fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'AEP Signalement <noreply@trans-former.fr>',
to: EMAIL_JULES,
subject: `[AEP] Signalement — ${category}`,
html: `
<h2>Signalement AEP — ${category}</h2>
<p><strong>Date :</strong> ${submittedAt}</p>
<p><strong>Catégorie :</strong> ${category}</p>
${email ? `<p><strong>Email expéditeur :</strong> ${email}</p>` : '<p><em>Pas d\'email fourni</em></p>'}
<p><strong>Description :</strong></p>
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
${description.replace(/\n/g, '<br/>')}
</blockquote>
`,
}),
})
} catch (e: any) {
console.error('[report-general] Erreur Resend:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur envoi email — réessaie dans quelques instants.',
})
}
return { ok: true, message: 'Signalement envoyé, merci !' }
})

94
server/api/report.post.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* POST /api/report
*
* Signalement d'erreur / proposition de modification sur une fiche
* Fallback Resend (pas de table NocoDB créée)
*
* Body : { fiche_id: number, message: string, email: string }
* Rate limit : 5/IP/jour
* Envoi vers jules@trans-former.fr via Resend API
*/
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
const EMAIL_JULES = process.env.EMAIL_JULES || 'jules@trans-former.fr'
export default defineEventHandler(async (event) => {
// 1. IP
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit 5/IP/jour
const allowed = checkRateLimitJson(ip, 'report', 5)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Limite de 5 signalements par jour atteinte.',
})
}
// 3. Lire le body
const body = await readBody(event)
const fiche_id: number = Number(body?.fiche_id ?? 0)
const message: string = (body?.message ?? '').trim()
const email: string = (body?.email ?? '').trim()
// 4. Validation
if (!fiche_id || fiche_id <= 0) {
throw createError({ statusCode: 400, statusMessage: 'fiche_id invalide.' })
}
if (!message || message.length < 5 || message.length > 500) {
throw createError({ statusCode: 400, statusMessage: 'Message requis (5-500 caractères).' })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!email || !emailRegex.test(email)) {
throw createError({ statusCode: 400, statusMessage: 'Email invalide.' })
}
// 5. Envoi via Resend
const resendApiKey = process.env.RESEND_API_KEY
if (!resendApiKey) {
console.error('[report] RESEND_API_KEY manquante')
throw createError({ statusCode: 500, statusMessage: 'Configuration email manquante.' })
}
const submittedAt = new Date().toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
try {
await $fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'AEP Signalement <noreply@trans-former.fr>',
to: EMAIL_JULES,
subject: `[AEP] Signalement fiche #${fiche_id}`,
html: `
<h2>Signalement fiche AEP #${fiche_id}</h2>
<p><strong>Date :</strong> ${submittedAt}</p>
<p><strong>Email expéditeur :</strong> ${email}</p>
<p><strong>Message :</strong></p>
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
${message.replace(/\n/g, '<br/>')}
</blockquote>
<hr/>
<p style="font-size:12px;color:#999;">
Voir la fiche : <a href="https://aep.trans-former.fr/fiche/${fiche_id}">https://aep.trans-former.fr/fiche/${fiche_id}</a>
</p>
`,
}),
})
} catch (e: any) {
console.error('[report] Erreur Resend:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur envoi email — réessaie dans quelques instants.',
})
}
return { ok: true, message: 'Signalement envoyé, merci !' }
})

106
server/api/stats.get.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* GET /api/stats
*
* Statistiques d'usage pour le bandeau bas (transparence IA + compteurs semaine).
*
* Query params :
* periode = "mois" (défaut) | "semaine"
*
* Payload retourné :
* cout_mois_eur number — somme cout_eur du mois courant
* budget_mois 20 — budget fixe mensuel
* tokens_mois number — somme tokens_in + tokens_out du mois
* co2_kg number — estimation CO2 (tokens × 0.000001 × 0.052 kgCO2eq/kWh mix RTE)
* requetes_mois number — count rows du mois
* fiches_semaine number — orgs approuvées créées dans les 7 derniers jours
* requetes_chatbot_semaine number — count stats_usage endpoint=chatbot dans les 7j
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const nocodbUrl = config.nocodbUrl as string
const nocodbToken = config.nocodbToken as string
const statsTableId = (config.statsTableId as string) || 'mbbq7n47ixy19mc'
const orgTableId = config.orgTableId as string
const nocoBaseId = process.env.NOCODB_BASE_ID || 'p_nav_v2'
const headers = { 'xc-token': nocodbToken }
// ── Dates ────────────────────────────────────────────────────────────────
const now = new Date()
const moisDebut = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
const semaineDebut = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
// ── Helpers fetch NocoDB ─────────────────────────────────────────────────
async function fetchStats(where: string): Promise<{ list: Record<string, any>[] }> {
try {
return await $fetch(`${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${statsTableId}`, {
headers,
query: { where, limit: 1000, fields: 'cout_eur,tokens_in,tokens_out,endpoint,created_at' },
}) as { list: Record<string, any>[] }
} catch {
return { list: [] }
}
}
async function fetchOrgsRecentes(since: string): Promise<number> {
if (!orgTableId) return 0
try {
const res = await $fetch<{ pageInfo?: { totalRows?: number } }>(
`${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`,
{
headers,
query: {
where: `(moderation_status,eq,approved)~and(created_at,gte,${since})`,
limit: 1,
fields: 'Id',
},
}
)
return (res as any)?.pageInfo?.totalRows ?? 0
} catch {
return 0
}
}
// ── Fetch parallèle ──────────────────────────────────────────────────────
const [statsMois, statsSemaine, fichesAjoutees] = await Promise.all([
fetchStats(`(created_at,gte,${moisDebut})`),
fetchStats(`(created_at,gte,${semaineDebut})`),
fetchOrgsRecentes(semaineDebut),
])
// ── Agrégation mois ──────────────────────────────────────────────────────
let cout_mois_eur = 0
let tokens_mois = 0
const requetes_mois = statsMois.list.length
for (const row of statsMois.list) {
cout_mois_eur += Number(row.cout_eur ?? 0)
tokens_mois += Number(row.tokens_in ?? 0) + Number(row.tokens_out ?? 0)
}
const co2_kg = tokens_mois * 0.000001 * 0.052
// ── Agrégation semaine chatbot ───────────────────────────────────────────
const requetes_chatbot_semaine = statsSemaine.list.filter(
(r) => (r.endpoint ?? '') === 'chatbot'
).length
// ── Réponse ──────────────────────────────────────────────────────────────
return {
cout_mois_eur: Math.round(cout_mois_eur * 1000) / 1000,
budget_mois: 20,
tokens_mois,
co2_kg: Math.round(co2_kg * 1e6) / 1e6,
requetes_mois,
fiches_semaine: fichesAjoutees,
requetes_chatbot_semaine,
}
})

View File

@@ -0,0 +1,241 @@
/**
* POST /api/submit
*
* Soumission d'une nouvelle organisation par un utilisateur.
* - Validation Zod côté serveur
* - Rate limit Redis : 3 soumissions / IP / jour
* - Géocodage Nominatim (optionnel — fallback silencieux)
* - INSERT NocoDB : moderation_status=pending, ai_processed=false
*
* Réponse 201 : { submissionId, message }
* Réponse 422 : erreurs Zod
* Réponse 429 : rate limit dépassé
*/
import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit'
// ── Schéma Zod ────────────────────────────────────────────────────────────────
const FONCTIONS = [
'Juridique',
'Technique',
'Économique',
'Administratif',
'Chantier',
'Comptabilité',
'Développement',
'Formation',
'Gestion d\'agence',
'Santé mentale',
] as const
const ECHELLES = ['National', 'Régional', 'Local'] as const
const TERRITOIRES = [
'Métropole',
'Guadeloupe',
'Martinique',
'Guyane',
'La Réunion',
'Mayotte',
] as const
export const SubmitSchema = z.object({
nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(),
url: z
.string()
.url('URL invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
description_user: z
.string()
.min(50, 'Minimum 50 caractères')
.max(500, 'Maximum 500 caractères')
.trim(),
echelle: z.enum(ECHELLES, { errorMap: () => ({ message: 'Échelle invalide' }) }),
fonctions: z
.array(z.enum(FONCTIONS))
.min(1, 'Sélectionne au moins une fonction')
.max(5, 'Maximum 5 fonctions'),
territoire: z.enum(TERRITOIRES, { errorMap: () => ({ message: 'Territoire invalide' }) }),
localisation_ville: z
.string()
.max(100)
.optional()
.transform((v) => v?.trim() || undefined),
submitted_by_email: z
.string()
.email('Email invalide')
.optional()
.or(z.literal(''))
.transform((v) => v || undefined),
})
export type SubmitInput = z.infer<typeof SubmitSchema>
// ── Géocodage Nominatim ───────────────────────────────────────────────────────
async function geocodeVille(
ville: string,
): Promise<{ lat: number; lon: number } | null> {
try {
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(ville)}&format=json&limit=1&countrycodes=fr`
const results: any[] = await $fetch(url, {
headers: { 'User-Agent': 'NAV/2.0 contact@trans-former.fr' },
timeout: 5000,
})
if (results.length > 0) {
return { lat: parseFloat(results[0].lat), lon: parseFloat(results[0].lon) }
}
} catch {
// Fallback silencieux — fiche stockée sans coordonnées
console.warn('[submit] Géocodage Nominatim échoué pour :', ville)
}
return null
}
// ── Handler principal ─────────────────────────────────────────────────────────
export default defineEventHandler(async (event) => {
// 1. Récupérer l'IP (proxy-aware)
const ip =
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
event.node.req.socket?.remoteAddress ||
'0.0.0.0'
// 2. Rate limit : 3 soumissions / IP / jour
const allowed = await checkRateLimit(ip, 'submit', 3)
if (!allowed) {
throw createError({
statusCode: 429,
statusMessage: 'Trop de soumissions. Réessaie demain.',
})
}
// 3. Lire et valider le body
const body = await readBody(event)
const parsed = SubmitSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
const data = parsed.data
// 4. Géocodage optionnel
let latitude: number | null = null
let longitude: number | null = null
if (data.localisation_ville) {
const coords = await geocodeVille(data.localisation_ville)
if (coords) {
latitude = coords.lat
longitude = coords.lon
}
}
// 5. INSERT NocoDB
const config = useRuntimeConfig()
const nocodbUrl = config.nocodbUrl
const nocodbToken = config.nocodbToken
const orgTableId = config.orgTableId
const nocoBaseId = config.nocodbBase
const payload: Record<string, unknown> = {
nom: data.nom,
url: data.url || null,
description_user: data.description_user,
echelle: data.echelle,
tags_fonction: data.fonctions.join(','),
territoire: data.territoire,
localisation_ville: data.localisation_ville || null,
submitted_by_email: data.submitted_by_email || null,
moderation_status: 'pending',
scrape_status: data.url ? 'pending' : 'no_link',
ai_processed: false,
submitted_at: new Date().toISOString(),
latitude,
longitude,
}
// NocoDB v1 endpoint
const insertUrl = `${nocodbUrl}/api/v1/db/data/noco/${nocoBaseId}/${orgTableId}`
let insertedRecord: any
try {
insertedRecord = await $fetch(insertUrl, {
method: 'POST',
headers: {
'xc-token': nocodbToken,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[submit] Erreur NocoDB insert:', e?.message ?? e)
throw createError({
statusCode: 502,
statusMessage: 'Erreur serveur — réessaie dans quelques instants.',
})
}
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
// 6. Notification email Jules (fire-and-forget — n'impacte pas la réponse)
notifyJules(config, data, submissionId).catch((e) =>
console.warn('[submit] Email notification échouée:', e?.message ?? e),
)
return {
status: 201,
submissionId,
message: 'Ta fiche est en cours de traitement.',
trackingUrl: submissionId
? `https://nav.trans-former.fr/suivi/${submissionId}`
: null,
}
})
async function notifyJules(
config: ReturnType<typeof useRuntimeConfig>,
data: SubmitInput,
submissionId: number | null,
): Promise<void> {
if (!config.resendApiKey) return
const nocoAdminUrl = `http://localhost:8070`
const body = {
from: 'AEP Carte <contact@trans-former.fr>',
to: [config.emailJules],
subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`,
html: `
<p><strong>Nouvelle fiche en attente de modération.</strong></p>
<ul>
<li><strong>Nom :</strong> ${data.nom}</li>
<li><strong>URL :</strong> ${data.url ?? '—'}</li>
<li><strong>Échelle :</strong> ${data.echelle}</li>
<li><strong>Territoire :</strong> ${data.territoire}</li>
<li><strong>Fonctions :</strong> ${data.fonctions.join(', ')}</li>
<li><strong>Description :</strong> ${data.description_user}</li>
${data.submitted_by_email ? `<li><strong>Email soumetteur :</strong> ${data.submitted_by_email}</li>` : ''}
${submissionId ? `<li><strong>ID NocoDB :</strong> ${submissionId}</li>` : ''}
</ul>
<p><a href="${nocoAdminUrl}">Ouvrir NocoDB pour valider</a></p>
`,
}
await $fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${config.resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
}