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,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