feat(aep): M1 — formulaire /proposer assoupli (liens + texte libre + chip)

Le contributeur colle un lien (ou plusieurs) et explique en texte libre
pourquoi c'est pertinent, sans template ni minimum de caractères. Le type
devient une suggestion optionnelle (chips). Les 5 formulaires typés restent
disponibles derrière « Mode détaillé » (replié par défaut).

- utils/submitLibre.ts : schéma Zod partagé client/serveur + mapping vers les
  colonnes NocoDB existantes (aucun changement de schéma) — table orgas par
  défaut, table ressources_references si le chip « Références » est choisi.
- components/FormLibre.vue : liens dynamiques (1-10), texte libre, chips,
  email optionnel, honeypot.
- server/api/submit/index.post.ts : branche mode=libre en amont du schéma
  typé existant ; email Jules remplacé par ntfy (server/utils/notify.ts) —
  jamais l'email ni le texte du contributeur dans la notif.
- scripts/test-submit-libre.mjs : 6 cas contre le vrai schéma Zod (via jiti).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jules
2026-09-27 15:57:11 +02:00
co-authored by Claude Opus 5.5
parent 58937a04a3
commit 09d1dc9d5f
6 changed files with 696 additions and 56 deletions
+122 -37
View File
@@ -14,6 +14,8 @@
import { z } from 'zod'
import { checkRateLimit } from '~/server/utils/rateLimit'
import { notifyNtfy, buildSubmissionNotif } from '~/server/utils/notify'
import { LibreSubmitSchema, toOrgaPayload, toReferencePayload, type LibreSubmitInput } from '~/utils/submitLibre'
const SUBMISSION_TYPES = ['ecosysteme', 'reseau', 'job', 'outil'] as const
@@ -103,6 +105,15 @@ export default defineEventHandler(async (event) => {
// 3. Lire et valider le body
const body = await readBody(event)
// Formulaire assoupli (B5-M1) — liens + texte libre, type suggéré optionnel.
if (body?.mode === 'libre') {
return handleLibreSubmit(body)
}
// (rate limit déjà vérifié ci-dessus pour les deux modes)
// Mode détaillé (5 formulaires typés, conservés derrière un lien discret)
const parsed = SubmitSchema.safeParse(body)
if (!parsed.success) {
@@ -188,10 +199,17 @@ export default defineEventHandler(async (event) => {
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),
)
// 6. Notification ntfy à Jules (fire-and-forget — n'impacte pas la réponse)
notifyNtfy(
process.env.NTFY_TOPIC,
buildSubmissionNotif({
submissionId,
nomSuggere: data.nom,
typeSuggere: data.submission_type,
nbLiens: data.url ? 1 : 0,
nocodbAdminUrl: 'http://localhost:8070',
}),
).catch((e) => console.warn('[submit] Notification ntfy échouée:', e?.message ?? e))
return {
status: 201,
@@ -203,40 +221,107 @@ export default defineEventHandler(async (event) => {
}
})
async function notifyJules(
config: ReturnType<typeof useRuntimeConfig>,
data: SubmitInput,
submissionId: number | null,
): Promise<void> {
if (!config.resendApiKey) return
// ── Formulaire assoupli (B5-M1) ─────────────────────────────────────────────
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>
`,
async function handleLibreSubmit(body: unknown) {
const parsed = LibreSubmitSchema.safeParse(body)
if (!parsed.success) {
throw createError({
statusCode: 422,
statusMessage: 'Validation échouée',
data: parsed.error.flatten().fieldErrors,
})
}
await $fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${config.resendApiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
const data = parsed.data
// Honeypot : un bot remplit ce champ caché — on répond succès sans rien écrire.
if (data.site_web) {
return {
status: 201,
submissionId: null,
message: 'Ta fiche est en cours de traitement.',
trackingUrl: null,
}
}
const config = useRuntimeConfig()
// Chip « Références » → table ressources_references, pas la table orgas.
if (data.type_suggere === 'references') {
return insertReferenceLibre(config, data)
}
const payload = toOrgaPayload(data)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.orgTableId}`
let insertedRecord: any
try {
insertedRecord = await $fetch(insertUrl, {
method: 'POST',
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[submit] Erreur NocoDB insert (libre):', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
}
const submissionId = insertedRecord?.Id ?? insertedRecord?.id ?? null
notifyNtfy(
process.env.NTFY_TOPIC,
buildSubmissionNotif({
submissionId,
nomSuggere: payload.nom,
typeSuggere: payload.submission_type,
nbLiens: data.liens.length,
nocodbAdminUrl: 'http://localhost:8070',
}),
).catch((e) => console.warn('[submit] Notification ntfy échouée (libre):', 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 insertReferenceLibre(config: ReturnType<typeof useRuntimeConfig>, data: LibreSubmitInput) {
const payload = toReferencePayload(data)
const insertUrl = `${config.nocodbUrl}/api/v1/db/data/noco/${config.nocodbBase}/${config.referencesTableId}`
let insertedRecord: any
try {
insertedRecord = await $fetch(insertUrl, {
method: 'POST',
headers: { 'xc-token': config.nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
} catch (e: any) {
console.error('[submit] Erreur NocoDB insert (référence libre):', e?.message ?? e)
throw createError({ statusCode: 502, statusMessage: 'Erreur serveur — réessaie dans quelques instants.' })
}
const referenceId = insertedRecord?.Id ?? insertedRecord?.id ?? null
notifyNtfy(
process.env.NTFY_TOPIC,
buildSubmissionNotif({
submissionId: referenceId,
nomSuggere: payload.titre,
typeSuggere: 'references',
nbLiens: data.liens.length,
nocodbAdminUrl: 'http://localhost:8070',
}),
).catch((e) => console.warn('[submit] Notification ntfy échouée (référence libre):', e?.message ?? e))
return {
status: 201,
submissionId: referenceId,
message: 'Ta référence est en attente de modération.',
trackingUrl: null,
}
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Notification ntfy.sh — remplace Resend (abandonné le 15/07, domaine non vérifié).
* POST https://ntfy.sh/${NTFY_TOPIC}
*
* RÈGLE : un topic ntfy est une URL, pas un coffre — le message ne contient
* JAMAIS l'email du contributeur ni le texte libre soumis. Seulement des
* métadonnées de modération (id NocoDB, nom suggéré, type, nb de liens).
*
* Si NTFY_TOPIC est absent : log et continue (pas d'erreur bloquante).
*/
export interface NtfyNotifyInput {
title: string
message: string
/** Priorité ntfy 1-5 (3 = défaut) */
priority?: number
tags?: string[]
/** Lien cliqué depuis la notif (ex : NocoDB) */
clickUrl?: string
}
export async function notifyNtfy(topic: string | undefined, input: NtfyNotifyInput): Promise<void> {
if (!topic) {
console.warn('[notify] NTFY_TOPIC absent — notification skippée:', input.title)
return
}
try {
const headers: Record<string, string> = {
Title: encodeHeaderValue(input.title),
Priority: String(input.priority ?? 3),
}
if (input.tags?.length) headers.Tags = input.tags.join(',')
if (input.clickUrl) headers.Click = input.clickUrl
const res = await fetch(`https://ntfy.sh/${topic}`, {
method: 'POST',
headers,
body: input.message,
})
if (!res.ok) {
console.warn('[notify] ntfy erreur HTTP:', res.status, await res.text())
}
} catch (e: any) {
console.warn('[notify] ntfy exception:', e?.message ?? e)
}
}
/**
* Construit le message de notification pour une nouvelle soumission.
* N'inclut jamais l'email ni le texte libre du contributeur.
*/
export function buildSubmissionNotif(input: {
submissionId: number | string | null
nomSuggere: string
typeSuggere: string
nbLiens: number
nocodbAdminUrl?: string
}): NtfyNotifyInput {
const { submissionId, nomSuggere, typeSuggere, nbLiens, nocodbAdminUrl } = input
return {
title: '[AEP] Nouvelle fiche soumise',
message: [
`Id NocoDB : ${submissionId ?? '—'}`,
`Nom suggéré : ${nomSuggere}`,
`Type suggéré : ${typeSuggere}`,
`Liens : ${nbLiens}`,
].join('\n'),
tags: ['inbox_tray'],
clickUrl: nocodbAdminUrl,
}
}
function encodeHeaderValue(v: string): string {
// ntfy accepte l'UTF-8 dans les headers mais certains proxies sont capricieux
// avec les accents en header brut — on reste simple, ntfy gère nativement.
return v
}