/** * POST /api/submit * * Soumission multi-type (ecosysteme / reseau / job / outil). * - Validation Zod côté serveur (champs communs + par type) * - 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' const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const 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 const TYPES_PRINCIPAUX = ['agence', 'collectif', 'reseau', 'asso', 'ressource'] as const const TYPES_PLATEFORME = ['mise-en-relation', 'appel-offre-public', 'communaute-pro'] as const const CATEGORIES_OUTIL = ['simulateur', 'logiciel', 'guide', 'autre'] as const export const SubmitSchema = z.object({ // Champs communs 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(), submitted_by_email: z.string().email('Email invalide').optional().or(z.literal('')).transform(v => v || undefined), submission_type: z.enum(SUBMISSION_TYPES, { errorMap: () => ({ message: 'Type de soumission invalide' }) }), // Champs écosystème echelle: z.enum(ECHELLES).optional(), fonctions: z.array(z.enum(FONCTIONS)).min(1).max(5).optional(), territoire: z.enum(TERRITOIRES).optional(), localisation_ville: z.string().max(100).optional().transform(v => v?.trim() || undefined), // Champs réseau type_principal: z.enum(TYPES_PRINCIPAUX).optional(), projet_lien: z.string().url('URL invalide').optional().or(z.literal('')), projet_adresse: z.string().max(200).optional().transform(v => v?.trim() || undefined), // Champs job type_plateforme: z.enum(TYPES_PLATEFORME).optional(), // Champs outil categorie: z.enum(CATEGORIES_OUTIL).optional(), }) export type SubmitInput = z.infer // ── 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 = { submission_type: data.submission_type, nom: data.nom, url: data.url || null, description_user: data.description_user, submitted_by_email: data.submitted_by_email || null, moderation_status: 'pending', ai_processed: false, submitted_at: new Date().toISOString(), latitude, longitude, } // Champs spécifiques selon le type if (data.submission_type === 'ecosysteme') { payload.echelle = data.echelle payload.tags_fonction = data.fonctions?.join(',') payload.territoire = data.territoire payload.localisation_ville = data.localisation_ville || null payload.scrape_status = data.url ? 'pending' : 'no_link' } else if (data.submission_type === 'reseau') { payload.type_principal = data.type_principal payload.projet_lien = data.projet_lien || null payload.projet_adresse = data.projet_adresse || null payload.localisation_ville = data.localisation_ville || null } else if (data.submission_type === 'job') { payload.type_plateforme = data.type_plateforme } else if (data.submission_type === 'outil') { payload.categorie = data.categorie } // 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, data: SubmitInput, submissionId: number | null, ): Promise { if (!config.resendApiKey) return const nocoAdminUrl = `http://localhost:8070` const body = { from: 'AEP Carte ', to: [config.emailJules], subject: `[AEP] Nouvelle fiche soumise : ${data.nom}`, html: `

Nouvelle fiche en attente de modération.

  • Nom : ${data.nom}
  • URL : ${data.url ?? '—'}
  • Échelle : ${data.echelle}
  • Territoire : ${data.territoire}
  • Fonctions : ${data.fonctions.join(', ')}
  • Description : ${data.description_user}
  • ${data.submitted_by_email ? `
  • Email soumetteur : ${data.submitted_by_email}
  • ` : ''} ${submissionId ? `
  • ID NocoDB : ${submissionId}
  • ` : ''}

Ouvrir NocoDB pour valider

`, } await $fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${config.resendApiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }) }