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),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user