B) Route Next.js /api/submit (TypeScript)
B) Route Next.js /api/submit (TypeScript)
Placez ce fichier dans app/api/submit/route.ts.
// app/api/submit/route.ts import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import crypto from "crypto"; // ---- Env ---- const BACKEND_INTAKE_URL = process.env.BACKEND_INTAKE_URL!; const HCAPTCHA_SECRET = process.env.HCAPTCHA_SECRET || ""; const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || "30", 10); // req / 10 min const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000; // ---- In-memory rate limit (simple, stateless, not for multi-node) ---- const buckets = new Map<string, { count: number; resetAt: number }>(); function rateLimit(ip: string) { const now = Date.now(); const bucket = buckets.get(ip); if (!bucket || now > bucket.resetAt) { buckets.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }); return { ok: true, remaining: RATE_LIMIT_MAX - 1, resetAt: now + RATE_LIMIT_WINDOW_MS }; } if (bucket.count >= RATE_LIMIT_MAX) { return { ok: false, remaining: 0, resetAt: bucket.resetAt }; } bucket.count += 1; return { ok: true, remaining: RATE_LIMIT_MAX - bucket.count, resetAt: bucket.resetAt }; } // ---- Zod schema (master) ---- const Schema = z.object({ declarante: z.object({ role: z.enum(["aidant", "senior", "pro"]), prenom: z.string().min(1), nom: z.string().min(1), email: z.string().email().optional().or(z.literal("")), phone: z.string().min(6).max(30).optional().or(z.literal("")), pref_channel: z.enum(["tel", "whatsapp", "email"]), }), senior: z.object({ prenom: z.string().min(1), nom: z.string().min(1), age: z.number().int().min(55).max(110), ville: z.string().min(1), vit_accompagne: z.enum(["seul", "conjoint", "famille", "aide"]), }), autonomie_risques: z.object({ dependence_level: z.enum(["autonome", "aide_partielle", "dependant", "tres_dependant"]), risk_fall: z.boolean(), risk_wandering: z.boolean(), cognitive_issues: z.boolean(), main_diagnosis: z.string().optional().default(""), }), habitat_iot: z.object({ home_adapted: z.enum(["non", "partiel", "oui"]), has_teleassistance: z.boolean(), has_internet: z.enum(["yes", "no", "unknown"]), iot_setup: z.array( z.enum(["teleassistance","capteur_mvt","montre_sante","tensiometre","capteur_lit","autre"]) ).optional().default([]), }), aides_besoins: z.object({ current_services: z.array(z.enum(["aide_vie","soins","logistique","supervision","social"])).optional().default([]), desired_services: z.array(z.enum(["aide_vie","soins","logistique","supervision","social"])).optional().default([]), medication_support: z.enum(["autonome","aide_famille","aide_pro"]), }), orga_budget: z.object({ contact_frequency: z.enum(["quotidien","hebdo","mensuel","rare"]), distance_caregiver_km: z.number().min(0).max(2000), budget_band: z.enum(["<300","300-600","600-1000",">1000","nspp"]), preference_cc: z.enum(["humain","mixte_humain_ia","ia"]), followup_mode: z.enum(["ponctuel","mensuel","hebdo","24_7"]), urgency_level: z.enum(["immediate","sous_7j","information"]), }), consent: z.object({ rgpd: z.boolean(), note: z.string().optional().default(""), timestamp_iso: z.string().min(10), // front must send ISO }), meta: z.object({ channel: z.string().default("web"), lang: z.string().default("fr"), hcaptcha_token: z.string().optional(), // from client-side widget }) }); function traceId() { return crypto.randomUUID(); } async function verifyHCaptcha(token?: string) { if (!HCAPTCHA_SECRET) return true; // disabled if no secret if (!token) return false; const params = new URLSearchParams(); params.append("secret", HCAPTCHA_SECRET); params.append("response", token); try { const resp = await fetch("https://hcaptcha.com/siteverify", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString(), }); const data = await resp.json(); return !!data.success; } catch { return false; } } async function forwardToBackend(json: unknown, trace: string) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); try { const attempt = async (delayMs: number) => { if (delayMs) await new Promise(r => setTimeout(r, delayMs)); const res = await fetch(BACKEND_INTAKE_URL, { method: "POST", headers: { "Content-Type": "application/json", "X-Trace-Id": trace, "X-Source": "webapp" }, body: JSON.stringify(json), signal: controller.signal }); if (!res.ok) throw new Error(`Backend ${res.status}`); return res.json().catch(() => ({})); }; // retries 0/1000/2000 ms try { return await attempt(0); } catch {} try { return await attempt(1000); } catch {} return await attempt(2000); } finally { clearTimeout(timeout); } } export async function POST(req: NextRequest) { const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "0.0.0.0"; // Rate limit const rl = rateLimit(ip); if (!rl.ok) { return NextResponse.json( { ok: false, error: "rate_limited", message: "Trop de tentatives, veuillez réessayer plus tard." }, { status: 429, headers: { "Retry-After": Math.ceil((rl.resetAt - Date.now())/1000).toString() } } ); } // Parse/validate let body: unknown; try { body = await req.json(); } catch { return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 }); } const parsed = Schema.safeParse(body); if (!parsed.success) { return NextResponse.json( { ok: false, error: "validation_failed", details: parsed.error.flatten() }, { status: 400 } ); } // hCaptcha const okCaptcha = await verifyHCaptcha(parsed.data.meta.hcaptcha_token); if (!okCaptcha) { return NextResponse.json( { ok: false, error: "captcha_failed" }, { status: 400 } ); } // Forward to backend (n8n / proxy) const tid = traceId(); try { const backendResp = await forwardToBackend(parsed.data, tid); return NextResponse.json({ ok: true, traceId: tid, backend: backendResp }, { status: 200 }); } catch (e: any) { return NextResponse.json( { ok: false, error: "backend_unreachable", traceId: tid, message: e?.message || "Backend error" }, { status: 502 } ); } }
.env.example
# URL de votre webhook n8n (ou proxy Node) BACKEND_INTAKE_URL=https://n8n.example.com/webhook/maintien-domicile-intake # Active la vérification hCaptcha (optionnel) : # FRONT doit envoyer meta.hcaptcha_token ; si vide, captcha désactivé côté serveur. HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000 # Limite de requêtes (par IP, fenêtre 10 minutes) RATE_LIMIT_MAX=30
Notes d’intégration
- Sur Vercel/Next 14 (App Router), cette route fonctionne server-only (aucun secret côté client).
- En prod multi-nœuds, remplacez le rate-limit mémoire par un service distribué (ex. Upstash Redis Ratelimit).
- Le retry côté serveur couvre les aléas réseau. Côté n8n, gardez le workflow idempotent (recherche avant création).