- Page /proposer avec 5 onglets (Écosystème/Réseau/Jobs/Outils/Références RAG) - 5 form components + form-styles.css partagé - Submit multi-type avec submission_type vers NocoDB - Server route references.post.ts réécrite pour NocoDB (table ressources_references) - redirect 301 /contribuer → /proposer - Suppression references-pending.json
243 lines
8.4 KiB
TypeScript
243 lines
8.4 KiB
TypeScript
/**
|
|
* 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<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> = {
|
|
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<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),
|
|
})
|
|
}
|