diff --git a/server/api/submit-pratique.post.ts b/server/api/submit-pratique.post.ts new file mode 100644 index 0000000..1173549 --- /dev/null +++ b/server/api/submit-pratique.post.ts @@ -0,0 +1,117 @@ +// Modération : Jules consulte public/data/pratiques-pending.json, +// déplace les entrées validées dans public/data/pratiques-regeneratives.json, +// supprime de pending. À automatiser en V2 (UI admin). + +import { z } from 'zod' +import { checkRateLimitJson } from '~/server/utils/rateLimitJson' +import { existsSync, readFileSync, writeFileSync } from 'fs' +import { resolve } from 'path' + +// ── Schéma Zod (miroir du client) ───────────────────────────────────────────── + +export const PratiqueSubmitSchema = z.object({ + nom: z.string().min(3, 'Minimum 3 caractères').max(150, 'Maximum 150 caractères').trim(), + url: z + .string() + .url('URL invalide (commencer par https://)') + .optional() + .or(z.literal('')) + .transform((v) => v || undefined), + description_user: z + .string() + .min(50, 'Minimum 50 caractères') + .max(500, 'Maximum 500 caractères') + .trim(), + criteres: z + .array(z.number().int().min(1).max(8)) + .min(3, 'Sélectionne au moins 3 critères') + .max(8, 'Maximum 8 critères'), + pays: z.string().length(2, 'Code pays invalide').or(z.literal('AUTRE')), + pays_autre: z.string().max(50).optional(), + ville: z.string().max(100).optional().transform((v) => v?.trim() || undefined), + type: z.enum([ + 'agence', 'cooperative', 'collectif', 'reseau', 'asso', + 'recherche', 'mouvement', 'plateforme', 'inconnu', + ], { errorMap: () => ({ message: 'Type d\'entité invalide' }) }), + tags: z.array(z.string().max(30)).max(6).optional(), + submitted_by_email: z + .string() + .email('Email invalide') + .optional() + .or(z.literal('')) + .transform((v) => v || undefined), +}) + +export type PratiqueSubmitInput = z.infer + +// ── Chemin du fichier pending ───────────────────────────────────────────────── + +function getPendingPath(): string { + return resolve(process.cwd(), 'public/data/pratiques-pending.json') +} + +function readPending(): PratiqueSubmitInput[] { + const path = getPendingPath() + try { + if (!existsSync(path)) return [] + return JSON.parse(readFileSync(path, 'utf-8')) + } catch { + return [] + } +} + +function writePending(entries: unknown[]) { + writeFileSync(getPendingPath(), JSON.stringify(entries, null, 2), 'utf-8') +} + +// ── Handler principal ───────────────────────────────────────────────────────── + +export default defineEventHandler(async (event) => { + // 1. Récupérer l'IP (proxy-aware) + const ip = + getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() || + event.node.req.socket?.remoteAddress || + '0.0.0.0' + + // 2. Rate limit JSON : 3 soumissions / IP / jour + const allowed = checkRateLimitJson(ip, 'submit-pratique', 3) + if (!allowed) { + throw createError({ + statusCode: 429, + statusMessage: 'Trop de soumissions. Réessaie demain.', + }) + } + + // 3. Lire et valider le body + const body = await readBody(event) + const parsed = PratiqueSubmitSchema.safeParse(body) + + if (!parsed.success) { + throw createError({ + statusCode: 422, + statusMessage: 'Validation échouée', + data: parsed.error.flatten().fieldErrors, + }) + } + + const data = parsed.data + + // 4. Construire l'entrée pending + const timestamp = Date.now() + const entry = { + ...data, + id: timestamp, + submitted_at: new Date().toISOString(), + moderation_status: 'pending' as const, + } + + // 5. Append à pratiques-pending.json + const pending = readPending() + pending.push(entry) + writePending(pending) + + return { + ok: true, + trackingId: timestamp, + } +})