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:
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<form @submit.prevent="submit" class="proposer-form proposer-form--libre" novalidate>
|
||||
<div class="field-group" :class="{ 'field-error': errors.texte }">
|
||||
<label id="libre-liens-label">Tes liens <span class="label-hint">(1 ou plusieurs — sites, PDF, articles...)</span></label>
|
||||
<div class="liens-list" role="group" aria-labelledby="libre-liens-label">
|
||||
<div v-for="(lien, i) in liens" :key="i" class="lien-row">
|
||||
<input
|
||||
:id="`libre-lien-${i}`"
|
||||
v-model="liens[i]"
|
||||
type="url"
|
||||
inputmode="url"
|
||||
placeholder="https://..."
|
||||
autocomplete="off"
|
||||
@blur="validateAll(false)"
|
||||
/>
|
||||
<button
|
||||
v-if="liens.length > 1"
|
||||
type="button"
|
||||
class="lien-remove"
|
||||
:aria-label="`Retirer ce lien`"
|
||||
@click="removeLien(i)"
|
||||
>×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="liens.length < 10" type="button" class="btn-add-lien" @click="addLien">+ un autre lien</button>
|
||||
<span v-if="errors.texte" id="libre-texte-error" class="error-msg" role="alert">{{ errors.texte }}</span>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label for="libre-texte">Pourquoi c'est utile, pour toi ? <span class="label-hint">(pas de format imposé)</span></label>
|
||||
<textarea
|
||||
id="libre-texte"
|
||||
v-model="texte"
|
||||
rows="4"
|
||||
placeholder="Dis-le avec tes mots : ce que ça t'a apporté, pourquoi tu le partages..."
|
||||
:aria-describedby="errors.texte ? 'libre-texte-error' : undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label id="libre-type-label">Type suggéré <span class="label-hint">(optionnel — ça nous aide à trier)</span></label>
|
||||
<ChipGroup aria-label="Type suggéré">
|
||||
<Chip
|
||||
v-for="t in TYPES_SUGGERES"
|
||||
:key="t"
|
||||
:label="TYPES_SUGGERES_LABELS[t]"
|
||||
variant="fonction"
|
||||
:active="typeSuggere === t"
|
||||
@toggle="toggleType(t)"
|
||||
/>
|
||||
</ChipGroup>
|
||||
</div>
|
||||
|
||||
<div class="field-group" :class="{ 'field-error': errors.submitted_by_email }">
|
||||
<label for="libre-email">Ton email <span class="label-hint">(optionnel — pour qu'on te tienne au courant)</span></label>
|
||||
<input
|
||||
id="libre-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
placeholder="ton@email.fr"
|
||||
autocomplete="email"
|
||||
@blur="validateAll(false)"
|
||||
/>
|
||||
<span v-if="errors.submitted_by_email" class="error-msg" role="alert">{{ errors.submitted_by_email }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Honeypot anti-spam : caché visuellement et aux lecteurs d'écran, un bot le remplit. -->
|
||||
<div class="hp-field" aria-hidden="true">
|
||||
<label for="libre-site-web">Site web</label>
|
||||
<input id="libre-site-web" v-model="siteWeb" type="text" tabindex="-1" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div v-if="serverError" class="server-error" role="alert" aria-live="assertive">
|
||||
<strong>Erreur :</strong> {{ serverError }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary" :disabled="submitting">
|
||||
{{ submitting ? 'Envoi en cours...' : 'Proposer →' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="form-note">Ta proposition sera examinée par l'équipe avant publication.</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LibreSubmitSchema, TYPES_SUGGERES, TYPES_SUGGERES_LABELS, type TypeSuggere } from '~/utils/submitLibre'
|
||||
|
||||
const props = defineProps<{ presetType?: TypeSuggere }>()
|
||||
const emit = defineEmits<{ success: [] }>()
|
||||
|
||||
const liens = reactive<string[]>([''])
|
||||
const texte = ref('')
|
||||
const email = ref('')
|
||||
const typeSuggere = ref<TypeSuggere | undefined>(props.presetType)
|
||||
const siteWeb = ref('') // honeypot
|
||||
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
const submitting = ref(false)
|
||||
const serverError = ref('')
|
||||
|
||||
function addLien() {
|
||||
if (liens.length < 10) liens.push('')
|
||||
}
|
||||
|
||||
function removeLien(i: number) {
|
||||
liens.splice(i, 1)
|
||||
if (liens.length === 0) liens.push('')
|
||||
}
|
||||
|
||||
function toggleType(t: TypeSuggere) {
|
||||
typeSuggere.value = typeSuggere.value === t ? undefined : t
|
||||
}
|
||||
|
||||
function currentPayload() {
|
||||
return {
|
||||
liens: liens.map((l) => l.trim()).filter(Boolean),
|
||||
texte: texte.value,
|
||||
submitted_by_email: email.value || undefined,
|
||||
type_suggere: typeSuggere.value,
|
||||
site_web: siteWeb.value,
|
||||
}
|
||||
}
|
||||
|
||||
function validateAll(scrollToError = true): boolean {
|
||||
const result = LibreSubmitSchema.safeParse(currentPayload())
|
||||
if (!result.success) {
|
||||
const flat = result.error.flatten().fieldErrors
|
||||
Object.keys(errors).forEach((k) => delete errors[k])
|
||||
Object.assign(errors, Object.fromEntries(Object.entries(flat).map(([k, v]) => [k, v?.[0] ?? ''])))
|
||||
if (scrollToError) {
|
||||
nextTick(() => {
|
||||
document.querySelector('.field-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
Object.keys(errors).forEach((k) => delete errors[k])
|
||||
return true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
serverError.value = ''
|
||||
if (!validateAll(true)) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await $fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: { mode: 'libre', ...currentPayload() },
|
||||
})
|
||||
emit('success')
|
||||
} catch (e: any) {
|
||||
const status = e?.status ?? e?.statusCode
|
||||
if (status === 429) {
|
||||
serverError.value = 'Tu as déjà soumis 3 fiches aujourd\'hui. Réessaie demain.'
|
||||
} else if (status === 422 && e?.data) {
|
||||
Object.assign(errors, Object.fromEntries(Object.entries(e.data as Record<string, string[]>).map(([k, v]) => [k, Array.isArray(v) ? v[0] : String(v)])))
|
||||
serverError.value = 'Vérifie les erreurs ci-dessus.'
|
||||
} else {
|
||||
serverError.value = 'Une erreur s\'est produite. Réessaie dans quelques instants.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './form-styles.css';
|
||||
|
||||
.liens-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
.lien-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lien-row input {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--nav-text);
|
||||
background: var(--nav-surface);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lien-row input:focus {
|
||||
outline: none;
|
||||
border-color: var(--nav-primary-solid);
|
||||
box-shadow: 0 0 0 2px rgba(245, 179, 66, 0.4);
|
||||
}
|
||||
.lien-remove {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid rgba(26, 34, 56, 0.2);
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
.lien-remove:hover {
|
||||
border-color: #c0392b;
|
||||
color: #c0392b;
|
||||
}
|
||||
.btn-add-lien {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed rgba(26, 34, 56, 0.3);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-text-muted);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-add-lien:hover {
|
||||
border-color: var(--nav-primary-solid);
|
||||
color: var(--nav-text);
|
||||
}
|
||||
|
||||
.hp-field {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
+54
-3
@@ -6,8 +6,8 @@
|
||||
<div class="proposer-header">
|
||||
<h1>Proposer une ressource</h1>
|
||||
<p class="proposer-subtitle">
|
||||
Tu connais quelque chose d'utile qui n'est pas encore référencé sur AEP ?
|
||||
Choisis la catégorie ci-dessous et soumets-le — on valide sous 7 jours.
|
||||
Colle un lien (ou plusieurs), dis en quelques mots pourquoi c'est utile — on s'occupe du reste.
|
||||
On valide manuellement avant publication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,23 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<FormLibre :preset-type="presetType" @success="onSuccess" />
|
||||
|
||||
<div class="mode-detaille">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-detaille-toggle"
|
||||
:aria-expanded="detailleOuvert"
|
||||
aria-controls="mode-detaille-panel"
|
||||
@click="detailleOuvert = !detailleOuvert"
|
||||
>
|
||||
{{ detailleOuvert ? '− Fermer le mode détaillé' : '+ Mode détaillé (formulaires par catégorie)' }}
|
||||
</button>
|
||||
|
||||
<div v-if="detailleOuvert" id="mode-detaille-panel" class="mode-detaille-panel">
|
||||
<p class="mode-detaille-hint">
|
||||
Pour aller plus loin qu'un lien + quelques mots : échelle, fonctions, ville... un formulaire par catégorie.
|
||||
</p>
|
||||
<div class="tabs-bar">
|
||||
<button
|
||||
v-for="tab in TABS"
|
||||
@@ -38,12 +55,16 @@
|
||||
<FormOutils v-else-if="activeTab === 'outils'" @success="onSuccess" />
|
||||
<FormReferences v-else-if="activeTab === 'references'" @success="onSuccess" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TYPES_SUGGERES, type TypeSuggere } from '~/utils/submitLibre'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'ecosysteme', label: 'Écosystème / Entraide' },
|
||||
{ id: 'reseau', label: 'Réseau AEP' },
|
||||
@@ -52,10 +73,15 @@ const TABS = [
|
||||
{ id: 'references', label: 'Références (bibliothèque)' },
|
||||
] as const
|
||||
|
||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne l'onglet
|
||||
// ?categorie=outils (lien « Proposer une ressource » des têtes d'échelle) présélectionne
|
||||
// le chip du formulaire assoupli ET l'onglet du mode détaillé si l'un ou l'autre est ouvert.
|
||||
const route = useRoute()
|
||||
const categorie = String(route.query.categorie ?? '')
|
||||
const presetType = computed<TypeSuggere | undefined>(() =>
|
||||
(TYPES_SUGGERES as readonly string[]).includes(categorie) ? (categorie as TypeSuggere) : undefined,
|
||||
)
|
||||
const activeTab = ref<string>(TABS.some(t => t.id === categorie) ? categorie : 'ecosysteme')
|
||||
const detailleOuvert = ref(false)
|
||||
const success = ref(false)
|
||||
|
||||
function onSuccess() {
|
||||
@@ -65,6 +91,7 @@ function onSuccess() {
|
||||
function reset() {
|
||||
success.value = false
|
||||
activeTab.value = 'ecosysteme'
|
||||
detailleOuvert.value = false
|
||||
}
|
||||
|
||||
useHead({ title: 'Proposer une ressource — AEP' })
|
||||
@@ -106,6 +133,30 @@ useHead({ title: 'Proposer une ressource — AEP' })
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mode-detaille {
|
||||
margin-top: 2rem;
|
||||
border-top: 1px solid var(--nav-bg-alt);
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
.mode-detaille-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--nav-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
padding: 0.25rem 0;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.mode-detaille-toggle:hover { color: var(--nav-text); }
|
||||
.mode-detaille-panel { margin-top: 1rem; }
|
||||
.mode-detaille-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--nav-text-muted);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.tabs-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test du schéma Zod du formulaire assoupli (B5-M1).
|
||||
* Charge le VRAI schéma TS (utils/submitLibre.ts) via jiti (déjà une dépendance
|
||||
* transitive de Nuxt, pas de nouveau paquet) — pas de réimplémentation parallèle.
|
||||
* Usage : node scripts/test-submit-libre.mjs
|
||||
*/
|
||||
|
||||
import { createJiti } from 'jiti'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const jiti = createJiti(import.meta.url)
|
||||
|
||||
const { LibreSubmitSchema } = await jiti.import(join(__dirname, '..', 'utils', 'submitLibre.ts'))
|
||||
|
||||
function baseInput(overrides) {
|
||||
return { liens: [], texte: '', site_web: '', ...overrides }
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: 'vide (aucun lien, aucun texte) → refus',
|
||||
input: baseInput({}),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: '1 lien seul → ok',
|
||||
input: baseInput({ liens: ['https://exemple.fr'] }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: 'texte seul → ok',
|
||||
input: baseInput({ texte: "Ça m'a beaucoup aidé pour ma reconversion." }),
|
||||
expectOk: true,
|
||||
},
|
||||
{
|
||||
name: '11 liens → refus',
|
||||
input: baseInput({ liens: Array.from({ length: 11 }, (_, i) => `https://exemple${i}.fr`) }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'URL invalide (pas de schéma) → refus',
|
||||
input: baseInput({ liens: ['exemple.fr'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
{
|
||||
name: 'ftp:// → refus',
|
||||
input: baseInput({ liens: ['ftp://exemple.fr/fichier.pdf'] }),
|
||||
expectOk: false,
|
||||
},
|
||||
]
|
||||
|
||||
let failures = 0
|
||||
|
||||
for (const c of cases) {
|
||||
const result = LibreSubmitSchema.safeParse(c.input)
|
||||
const pass = result.success === c.expectOk
|
||||
const detail = result.success ? '' : ` (${JSON.stringify(result.error.flatten().fieldErrors)})`
|
||||
console.log(`${pass ? 'PASS' : 'FAIL'} — ${c.name}${pass ? '' : detail}`)
|
||||
if (!pass) failures++
|
||||
}
|
||||
|
||||
console.log(`\n${cases.length - failures}/${cases.length} tests passés`)
|
||||
process.exit(failures > 0 ? 1 : 0)
|
||||
+120
-35
@@ -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', {
|
||||
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: {
|
||||
Authorization: `Bearer ${config.resendApiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* B5-M1 — Schéma + mapping du formulaire « proposer » assoupli.
|
||||
*
|
||||
* LE PRINCIPE (décision Jules) : le contributeur colle un lien (ou une liste
|
||||
* de liens) + explique en texte libre pourquoi c'est pertinent. Pas de
|
||||
* description obligatoire, pas de limite de mots, pas de template imposé.
|
||||
* Le typage devient une suggestion optionnelle (chips).
|
||||
*
|
||||
* Partagé client (formulaire, validation immédiate) et serveur (validation
|
||||
* finale + mapping vers les colonnes NocoDB existantes — aucun changement de
|
||||
* schéma, cf. B5-proposer-pipe.md écart #4).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Chips optionnelles — mêmes 5 catégories que les onglets historiques de /proposer. */
|
||||
export const TYPES_SUGGERES = ['ecosysteme', 'reseau', 'jobs', 'outils', 'references'] as const
|
||||
export type TypeSuggere = (typeof TYPES_SUGGERES)[number]
|
||||
|
||||
export const TYPES_SUGGERES_LABELS: Record<TypeSuggere, string> = {
|
||||
ecosysteme: 'Écosystème / Entraide',
|
||||
reseau: 'Réseau AEP',
|
||||
jobs: 'Jobs',
|
||||
outils: 'Outils',
|
||||
references: 'Références',
|
||||
}
|
||||
|
||||
/** URL http(s) uniquement — un `ftp://` ou autre schéma est invalide. */
|
||||
const httpUrl = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => /^https?:\/\/.+/i.test(v), { message: 'URL invalide (doit commencer par http:// ou https://)' })
|
||||
|
||||
export const LibreSubmitSchema = z
|
||||
.object({
|
||||
liens: z.array(httpUrl).max(10, 'Maximum 10 liens').default([]),
|
||||
texte: z.string().max(8000, 'Maximum 8000 caractères').default(''),
|
||||
submitted_by_email: z
|
||||
.string()
|
||||
.email('Email invalide')
|
||||
.optional()
|
||||
.or(z.literal(''))
|
||||
.transform((v) => v || undefined),
|
||||
type_suggere: z.enum(TYPES_SUGGERES).optional().or(z.literal('')).transform((v) => (v ? v : undefined)),
|
||||
// Honeypot anti-spam : champ caché, doit rester vide pour un humain.
|
||||
site_web: z.string().optional().or(z.literal('')),
|
||||
})
|
||||
.refine((data) => data.liens.length > 0 || data.texte.trim().length > 0, {
|
||||
message: 'Ajoute au moins un lien ou quelques mots.',
|
||||
path: ['texte'],
|
||||
})
|
||||
|
||||
export type LibreSubmitInput = z.infer<typeof LibreSubmitSchema>
|
||||
|
||||
/** Chip 'jobs'/'outils' → valeur `submission_type` en base (table orgas). */
|
||||
const TYPE_TO_SUBMISSION_TYPE: Record<Exclude<TypeSuggere, 'references'>, 'ecosysteme' | 'reseau' | 'job' | 'outil'> = {
|
||||
ecosysteme: 'ecosysteme',
|
||||
reseau: 'reseau',
|
||||
jobs: 'job',
|
||||
outils: 'outil',
|
||||
}
|
||||
|
||||
function hostnameOf(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nomSuggere(data: LibreSubmitInput): string {
|
||||
const premierLien = data.liens[0]
|
||||
const base = premierLien ? hostnameOf(premierLien) : null
|
||||
const fallback = data.texte.trim().slice(0, 60)
|
||||
return `[à qualifier] ${base || fallback || 'sans titre'}`
|
||||
}
|
||||
|
||||
function descriptionAvecLiens(texte: string, liens: string[], typeNonPrecise: boolean): string {
|
||||
const parts: string[] = []
|
||||
if (typeNonPrecise) parts.push('Type : non précisé')
|
||||
if (texte.trim()) parts.push(texte.trim())
|
||||
if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`)
|
||||
return parts.join('\n\n')
|
||||
}
|
||||
|
||||
/** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */
|
||||
export function toOrgaPayload(data: LibreSubmitInput) {
|
||||
const typeNonPrecise = !data.type_suggere || data.type_suggere === 'references'
|
||||
const submissionType = data.type_suggere && data.type_suggere !== 'references'
|
||||
? TYPE_TO_SUBMISSION_TYPE[data.type_suggere]
|
||||
: 'ecosysteme'
|
||||
|
||||
return {
|
||||
submission_type: submissionType,
|
||||
nom: nomSuggere(data),
|
||||
url: data.liens[0] || null,
|
||||
description_user: descriptionAvecLiens(data.texte, data.liens, typeNonPrecise),
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
ai_processed: false,
|
||||
submitted_at: new Date().toISOString(),
|
||||
scrape_status: data.liens[0] ? 'pending' : 'no_link',
|
||||
}
|
||||
}
|
||||
|
||||
/** Mapping vers la table ressources_references (chip « Références »). */
|
||||
export function toReferencePayload(data: LibreSubmitInput) {
|
||||
return {
|
||||
titre: nomSuggere(data),
|
||||
auteur: 'non précisé',
|
||||
url: data.liens[0] || null,
|
||||
description: descriptionAvecLiens(data.texte, data.liens, false),
|
||||
pertinence_rag: null,
|
||||
hashtags: null,
|
||||
submitted_by_email: data.submitted_by_email || null,
|
||||
moderation_status: 'pending' as const,
|
||||
submitted_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user