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

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

View File

@@ -0,0 +1,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),
})
}