/** * 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 = { ecosysteme: 'Écosystème / Entraide', reseau: 'Réseau AEP', jobs: 'Jobs', outils: 'Outils', references: 'Références', } /** * Légende courte sous les chips — définitions tirées des textes existants du site : * ecosysteme/jobs/outils/references reprennent la tête d'onglet (`TABS`, pages/proposer.vue) * et/ou la phrase de l'échelle qui pointe vers ce type (utils/echelles.ts) ; reseau reprend * la tête d'onglet « Réseau AEP » et les champs projet/lien de FormReseau.vue. */ export const TYPES_SUGGERES_HINTS: Record = { ecosysteme: 'entraide, mutualisation', reseau: 'acteurs et projets AEP', jobs: 'offres, plateformes d\'emploi', outils: 'ressources, méthodes', references: 'bibliothèque, ouvrages', } /** 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)), // Deuxième chip optionnelle (B5-M2b) — indécis entre deux types. type_secondaire: 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'], }) .refine((data) => !data.type_secondaire || !!data.type_suggere, { message: 'Choisis d\'abord un type principal.', path: ['type_secondaire'], }) .refine((data) => !data.type_secondaire || data.type_secondaire !== data.type_suggere, { message: 'Le second type doit être différent du premier.', path: ['type_secondaire'], }) export type LibreSubmitInput = z.infer /** Chip 'jobs'/'outils' → valeur `submission_type` en base (table orgas). */ const TYPE_TO_SUBMISSION_TYPE: Record, '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'}` } /** * `prefixLine` est soit le marqueur worker « Type : non précisé » (aucun type choisi), * soit la ligne « Type suggéré : X ou Y » (double type) — jamais les deux à la fois, * et toujours en toute première position quand présent (contrat worker/enrich.js:562). */ function descriptionAvecLiens(texte: string, liens: string[], prefixLine?: string): string { const parts: string[] = [] if (prefixLine) parts.push(prefixLine) if (texte.trim()) parts.push(texte.trim()) if (liens.length) parts.push(`Liens :\n${liens.join('\n')}`) return parts.join('\n\n') } function ligneTypeSuggere(data: LibreSubmitInput): string | undefined { if (!data.type_secondaire) return undefined // Le schéma garantit type_suggere défini dès que type_secondaire l'est. return `Type suggéré : ${TYPES_SUGGERES_LABELS[data.type_suggere!]} ou ${TYPES_SUGGERES_LABELS[data.type_secondaire]}` } /** Mapping vers la table orgas existante (submission_type ecosysteme/reseau/job/outil). */ export function toOrgaPayload(data: LibreSubmitInput) { const hasSecondaire = !!data.type_secondaire // Marqueur worker posé uniquement quand aucun type exploitable n'a été choisi du tout — // un double type ne le pose jamais (contrat worker/enrich.js:562). const typeNonPrecise = !hasSecondaire && (!data.type_suggere || data.type_suggere === 'references') const submissionType = data.type_suggere && data.type_suggere !== 'references' ? TYPE_TO_SUBMISSION_TYPE[data.type_suggere] : 'ecosysteme' const prefixLine = typeNonPrecise ? 'Type : non précisé' : ligneTypeSuggere(data) return { submission_type: submissionType, nom: nomSuggere(data), url: data.liens[0] || null, description_user: descriptionAvecLiens(data.texte, data.liens, prefixLine), 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, ligneTypeSuggere(data)), pertinence_rag: null, hashtags: null, submitted_by_email: data.submitted_by_email || null, moderation_status: 'pending' as const, submitted_at: new Date().toISOString(), } }