feat(pratiques): endpoint POST /api/submit-pratique avec Zod + rate limit
Validation Zod miroir schéma client, 3 soumissions/IP/jour via rateLimitJson, append à pratiques-pending.json, retourne trackingId. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
117
server/api/submit-pratique.post.ts
Normal file
117
server/api/submit-pratique.post.ts
Normal file
@@ -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<typeof PratiqueSubmitSchema>
|
||||||
|
|
||||||
|
// ── 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user