feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
331
server/api/chatbot.post.ts
Normal file
331
server/api/chatbot.post.ts
Normal 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
|
||||
})
|
||||
22
server/api/comment/[orgId].get.ts
Normal file
22
server/api/comment/[orgId].get.ts
Normal 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 ?? [] }
|
||||
})
|
||||
183
server/api/comment/index.post.ts
Normal file
183
server/api/comment/index.post.ts
Normal 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.',
|
||||
}
|
||||
})
|
||||
26
server/api/fiche/[id].get.ts
Normal file
26
server/api/fiche/[id].get.ts
Normal 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
|
||||
})
|
||||
94
server/api/report-general.post.ts
Normal file
94
server/api/report-general.post.ts
Normal 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
94
server/api/report.post.ts
Normal 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
106
server/api/stats.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
241
server/api/submit/index.post.ts
Normal file
241
server/api/submit/index.post.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
16
server/routes/api/avis.post.ts
Normal file
16
server/routes/api/avis.post.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const body = await readBody(event)
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
13
server/routes/api/avis/[orgId].get.ts
Normal file
13
server/routes/api/avis/[orgId].get.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const orgId = getRouterParam(event, 'orgId')
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.avisTableId}/records?where=(organisation_id,eq,${orgId})~and(status,eq,approved)`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
},
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
50
server/routes/api/organisations.get.ts
Normal file
50
server/routes/api/organisations.get.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// Paramètre optionnel ?format=map pour retourner uniquement les champs carte
|
||||
const query = getQuery(event)
|
||||
|
||||
try {
|
||||
// NocoDB V2 — table organisations, filtre approved + published
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records?where=(moderation_status,eq,approved)&limit=300&sort=-Id`
|
||||
|
||||
const data: any = await $fetch(url, {
|
||||
headers: { 'xc-token': config.nocodbToken },
|
||||
timeout: 8000,
|
||||
})
|
||||
|
||||
return { list: data?.list ?? [], source: 'nocodb' }
|
||||
} catch (err) {
|
||||
// Fallback seed JSON pour dev local si NocoDB inaccessible
|
||||
console.warn('[NAV API] NocoDB inaccessible, fallback seed JSON:', err)
|
||||
|
||||
try {
|
||||
const seedPath = resolve(process.cwd(), 'V2-cadrage/seed-94-fiches-v2.json')
|
||||
const raw = readFileSync(seedPath, 'utf-8')
|
||||
const seed: any[] = JSON.parse(raw)
|
||||
|
||||
// Normaliser le seed au format NocoDB (ajouter Id fictif)
|
||||
const list = seed.map((item: any, i: number) => ({
|
||||
Id: 1000 + i,
|
||||
nom: item.nom,
|
||||
url: item.url,
|
||||
description: item.description,
|
||||
echelle: item.echelle,
|
||||
tags_fonction: item.fonctions?.join(',') ?? '',
|
||||
territoire: item.territoire,
|
||||
localisation_ville: item.ville,
|
||||
latitude: item.lat,
|
||||
longitude: item.lon,
|
||||
moderation_status: 'approved',
|
||||
prioritaire: false,
|
||||
}))
|
||||
|
||||
return { list, source: 'seed' }
|
||||
} catch (seedErr) {
|
||||
throw createError({ statusCode: 503, message: 'Service indisponible — NocoDB et seed inaccessibles' })
|
||||
}
|
||||
}
|
||||
})
|
||||
16
server/routes/api/organisations.post.ts
Normal file
16
server/routes/api/organisations.post.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const body = await readBody(event)
|
||||
const url = `${config.nocodbUrl}/api/v2/tables/${config.orgTableId}/records`
|
||||
|
||||
const data = await $fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'xc-token': config.nocodbToken,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
return data
|
||||
})
|
||||
17
server/routes/api/organisations/[id].get.ts
Normal file
17
server/routes/api/organisations/[id].get.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig()
|
||||
const id = getRouterParam(event, 'id')
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
const record = data?.list?.[0] ?? null
|
||||
if (!record) {
|
||||
throw createError({ statusCode: 404, message: 'Organisation non trouvée' })
|
||||
}
|
||||
return record
|
||||
})
|
||||
87
server/utils/circuitBreaker.ts
Normal file
87
server/utils/circuitBreaker.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Circuit breaker budget IA
|
||||
* Spec F §6 — seuil 20€/mois
|
||||
*
|
||||
* Avant chaque appel IA (worker ou chatbot) :
|
||||
* const { blocked } = await checkBudget(config)
|
||||
* if (blocked) throw createError({ statusCode: 503, ... })
|
||||
*
|
||||
* Paliers :
|
||||
* >= 15€ → email Jules (géré par le worker)
|
||||
* >= 18€ → flag budget_warning (bandeau site)
|
||||
* >= 20€ → hard stop, HTTP 503
|
||||
*/
|
||||
|
||||
export const BUDGET_MAX_EUR = 20
|
||||
export const BUDGET_WARN_EUR = 18
|
||||
|
||||
export interface BudgetStatus {
|
||||
cumulEur: number
|
||||
blocked: boolean
|
||||
warning: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le cumul de dépenses IA du mois courant depuis stats_usage NocoDB.
|
||||
* Retourne blocked=true si le budget est atteint.
|
||||
*/
|
||||
export async function checkBudget(config: {
|
||||
nocodbUrl: string
|
||||
nocodbToken: string
|
||||
statsTableId: string
|
||||
}): Promise<BudgetStatus> {
|
||||
const { nocodbUrl, nocodbToken, statsTableId } = config
|
||||
|
||||
// Premier du mois courant à minuit UTC
|
||||
const now = new Date()
|
||||
const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
|
||||
const monthStartIso = monthStart.toISOString()
|
||||
|
||||
try {
|
||||
// Fetch toutes les entrées du mois courant (NocoDB v2)
|
||||
const url = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
|
||||
|
||||
const res = await $fetch<{ list: { cout_eur: number | null; timestamp: string }[] }>(
|
||||
url,
|
||||
{
|
||||
headers: { 'xc-token': nocodbToken },
|
||||
query: {
|
||||
where: `(timestamp,gte,${monthStartIso})`,
|
||||
limit: 1000,
|
||||
fields: 'cout_eur,timestamp',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const rows = res?.list ?? []
|
||||
const cumulEur = rows.reduce((sum, row) => sum + (Number(row.cout_eur) || 0), 0)
|
||||
|
||||
return {
|
||||
cumulEur,
|
||||
blocked: cumulEur >= BUDGET_MAX_EUR,
|
||||
warning: cumulEur >= BUDGET_WARN_EUR,
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur de lecture, on ne bloque PAS pour ne pas pénaliser les utilisateurs
|
||||
console.warn('[circuitBreaker] Erreur lecture stats_usage — budget non vérifié:', (e as Error).message)
|
||||
return { cumulEur: 0, blocked: false, warning: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le coût en EUR d'un appel Mistral Small.
|
||||
* Prix : $0.20/M tokens_in, $0.60/M tokens_out (converti en EUR @0.93)
|
||||
*/
|
||||
export function calcCoutMistralSmall(tokensIn: number, tokensOut: number): number {
|
||||
const usd = (tokensIn / 1_000_000) * 0.2 + (tokensOut / 1_000_000) * 0.6
|
||||
return usd * 0.93
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule le coût en EUR d'un appel Mistral Nemo.
|
||||
* Prix : $0.02/M tokens_in, $0.04/M tokens_out (converti en EUR @0.93)
|
||||
*/
|
||||
export function calcCoutMistralNemo(tokensIn: number, tokensOut: number): number {
|
||||
const usd = (tokensIn / 1_000_000) * 0.02 + (tokensOut / 1_000_000) * 0.04
|
||||
return usd * 0.93
|
||||
}
|
||||
109
server/utils/rateLimit.ts
Normal file
109
server/utils/rateLimit.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Rate limiting via Redis (ioredis)
|
||||
* Fallback : compteur en mémoire si Redis indisponible (dev local sans Docker)
|
||||
*
|
||||
* Usage :
|
||||
* const allowed = await checkRateLimit(ip, 'submit', 3)
|
||||
* if (!allowed) throw createError({ statusCode: 429, ... })
|
||||
*/
|
||||
|
||||
import Redis from 'ioredis'
|
||||
|
||||
let redisClient: Redis | null = null
|
||||
let redisConnected = false
|
||||
|
||||
// Compteur en mémoire (fallback si Redis KO)
|
||||
const memoryCounters: Map<string, { count: number; resetAt: number }> = new Map()
|
||||
|
||||
function getRedisClient(): Redis | null {
|
||||
if (redisClient !== null) return redisConnected ? redisClient : null
|
||||
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
const url = config.redisUrl || 'redis://127.0.0.1:6379'
|
||||
redisClient = new Redis(url, {
|
||||
lazyConnect: true,
|
||||
enableOfflineQueue: false,
|
||||
maxRetriesPerRequest: 1,
|
||||
connectTimeout: 2000,
|
||||
})
|
||||
|
||||
redisClient.on('connect', () => {
|
||||
redisConnected = true
|
||||
console.log('[rateLimit] Redis connecté')
|
||||
})
|
||||
|
||||
redisClient.on('error', (err) => {
|
||||
redisConnected = false
|
||||
console.warn('[rateLimit] Redis erreur — fallback mémoire activé:', err.message)
|
||||
})
|
||||
|
||||
redisClient.connect().catch(() => {
|
||||
redisConnected = false
|
||||
})
|
||||
|
||||
return redisConnected ? redisClient : null
|
||||
} catch (e) {
|
||||
console.warn('[rateLimit] Impossible de créer le client Redis — fallback mémoire')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie et incrémente le compteur d'appels.
|
||||
* @param ip Adresse IP du client
|
||||
* @param action Clé d'action (ex : 'submit', 'comment')
|
||||
* @param maxPerDay Nombre max d'appels autorisés par jour
|
||||
* @returns true si autorisé, false si limite dépassée
|
||||
*/
|
||||
export async function checkRateLimit(
|
||||
ip: string,
|
||||
action: string,
|
||||
maxPerDay: number,
|
||||
): Promise<boolean> {
|
||||
const key = `ratelimit:${action}:${ip}:${todayKey()}`
|
||||
|
||||
const client = getRedisClient()
|
||||
|
||||
if (client && redisConnected) {
|
||||
try {
|
||||
const count = await client.incr(key)
|
||||
if (count === 1) {
|
||||
// Expire à minuit (secondes restantes dans la journée)
|
||||
await client.expireat(key, tomorrowMidnightUnix())
|
||||
}
|
||||
return count <= maxPerDay
|
||||
} catch (e) {
|
||||
console.warn('[rateLimit] Redis incr échoué — fallback mémoire:', (e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback mémoire
|
||||
return memoryRateLimit(key, maxPerDay)
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function todayKey(): string {
|
||||
const d = new Date()
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function tomorrowMidnightUnix(): number {
|
||||
const d = new Date()
|
||||
d.setUTCHours(24, 0, 0, 0)
|
||||
return Math.floor(d.getTime() / 1000)
|
||||
}
|
||||
|
||||
function memoryRateLimit(key: string, maxPerDay: number): boolean {
|
||||
const now = Date.now()
|
||||
const entry = memoryCounters.get(key)
|
||||
|
||||
if (!entry || entry.resetAt <= now) {
|
||||
memoryCounters.set(key, { count: 1, resetAt: tomorrowMidnightUnix() * 1000 })
|
||||
return true
|
||||
}
|
||||
|
||||
entry.count++
|
||||
return entry.count <= maxPerDay
|
||||
}
|
||||
87
server/utils/rateLimitJson.ts
Normal file
87
server/utils/rateLimitJson.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Rate limiting via fichiers JSON locaux
|
||||
* Implémentation recommandée spec F §8 : /tmp/nav-ratelimit/{IP_hash}.json
|
||||
*
|
||||
* - IP hashée SHA-256 (RGPD — pas de stockage IP en clair)
|
||||
* - Fichier JSON par IP, reset automatique si date != today
|
||||
* - Dossier créé au premier appel si absent
|
||||
*
|
||||
* Usage :
|
||||
* const allowed = await checkRateLimitJson(ip, 'chatbot', 10)
|
||||
* if (!allowed) throw createError({ statusCode: 429, ... })
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
|
||||
const RATELIMIT_DIR = '/tmp/nav-ratelimit'
|
||||
|
||||
type RateLimitFile = {
|
||||
[action: string]: { count: number; date: string }
|
||||
}
|
||||
|
||||
function ensureDir() {
|
||||
if (!existsSync(RATELIMIT_DIR)) {
|
||||
mkdirSync(RATELIMIT_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
function hashIp(ip: string): string {
|
||||
return createHash('sha256').update(ip).digest('hex')
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function readFile(ipHash: string): RateLimitFile {
|
||||
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeFile(ipHash: string, data: RateLimitFile) {
|
||||
const path = join(RATELIMIT_DIR, `${ipHash}.json`)
|
||||
writeFileSync(path, JSON.stringify(data), 'utf-8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie et incrémente le compteur pour une IP et une action.
|
||||
* @param ip Adresse IP du client (sera hashée SHA-256)
|
||||
* @param action Clé d'action (ex : 'chatbot', 'submit', 'comment')
|
||||
* @param maxPerDay Nombre max d'appels autorisés par jour
|
||||
* @returns true si autorisé, false si limite dépassée
|
||||
*/
|
||||
export function checkRateLimitJson(
|
||||
ip: string,
|
||||
action: string,
|
||||
maxPerDay: number,
|
||||
): boolean {
|
||||
ensureDir()
|
||||
|
||||
const ipHash = hashIp(ip)
|
||||
const today = todayStr()
|
||||
const data = readFile(ipHash)
|
||||
|
||||
const entry = data[action]
|
||||
|
||||
if (!entry || entry.date !== today) {
|
||||
// Nouveau jour ou premier appel : reset et autoriser
|
||||
data[action] = { count: 1, date: today }
|
||||
writeFile(ipHash, data)
|
||||
return true
|
||||
}
|
||||
|
||||
if (entry.count >= maxPerDay) {
|
||||
return false
|
||||
}
|
||||
|
||||
entry.count++
|
||||
writeFile(ipHash, data)
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user