Files
nav-carte/utils/submitLibre.ts
T
Jules NenyandClaude Opus 5.5 a1edcf3093 feat(b5): M2b — légende des cinq types et double type pour les indécis
Deux chips maximum : le premier alimente submission_type, le second
s'écrit en tête de description_user (« Type suggéré : X ou Y »), sans
ALTER NocoDB. Le marqueur « Type : non précisé » n'est posé que sans
aucun type (contrat worker/enrich.js inchangé). ?categorie= présélectionne
un seul chip. Tests : 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvxdoEGWjCTSEJDmraYW96
2026-09-28 12:14:54 +02:00

159 lines
6.4 KiB
TypeScript

/**
* 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',
}
/**
* 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<TypeSuggere, string> = {
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<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'}`
}
/**
* `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(),
}
}