Files
clienthub/src/app/api/webhooks/lead/route.ts
T
simone 19ed377214 feat(pipeline): endpoint di ingresso lead, indipendente dalla sorgente
POST /api/webhooks/lead con header x-webhook-secret. Un endpoint solo per il
form del sito e per qualunque bridge: il contratto e' un POST, e chi lo manda
non cambia la route.

La normalizzazione dei campi sta in lead-intake.ts, non nella route, perche' e'
la parte che cambia quando si aggiunge una sorgente. Regge tre forme senza
doverle distinguere: payload piatto, `fields` annidati con {value} (Elementor
Pro), e urlencoded per i form che non mandano JSON. Riconosce i nomi italiani
(nome, telefono, azienda, messaggio), che e' come li chiama un form Elementor
scritto in italiano.

Chi compila due volte non diventa due lead. Il riconoscimento e' sull'email: il
secondo invio aggiorna last_contact_date e lascia un'attivita' con quello che
ha scritto, cosi' il messaggio non si perde ma la scheda resta una. Senza email
non si puo' dedurre nulla e si crea.

Due scelte di sicurezza, entrambe diverse dalle route /api/internal:

- Segreto assente in ambiente = 403, non "passa". Le internal possono
  permetterselo perche' sono raggiungibili solo da localhost; questa e' esposta
  a internet, e un deploy con la variabile dimenticata deve smettere di
  accettare lead, non accettarli da chiunque.
- Il rate limit viene PRIMA del confronto sul segreto, altrimenti tentare
  segreti a raffica costerebbe zero. Confronto a tempo costante con safeEqual,
  lo stesso del gate admin.

src/proxy.ts non intercetta /api/*, quindi da monte non arriva nessuna
protezione: sta tutto dentro la route.

Provato contro il DB di produzione via tunnel SSH, poi ripulito (2 lead e 2
attivita' prima, 2 e 2 dopo): senza segreto 403, segreto sbagliato 403, nome
mancante 422, payload piatto 201, ripetuto 200 "updated" senza duplicare,
forma Elementor 201 con nome/telefono/messaggio mappati, urlencoded 201, e con
starts_at valorizzato il lead nasce con la data della call e un'attivita'
"meeting".

Resta da confermare con un invio VERO da Elementor la forma esatta del suo
payload: qui e' gestita in modo difensivo, non verificata sul campo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:57:42 +02:00

93 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { rateLimit } from "@/lib/rate-limit";
import { safeEqual } from "@/lib/admin-gate";
import { ingestLead, leadIntakeSchema, normalizeLeadPayload } from "@/lib/lead-intake";
/**
* Ingresso lead dall'esterno — form del sito, bridge Zapier/Make, qualunque
* cosa sappia fare un POST JSON.
*
* curl -X POST https://…/api/webhooks/lead \
* -H 'content-type: application/json' \
* -H "x-webhook-secret: $LEAD_WEBHOOK_SECRET" \
* -d '{"name":"Mario Rossi","email":"mario@example.com","source":"form-home"}'
*
* Due differenze rispetto alle route in /api/internal, e sono volute:
*
* 1. `src/proxy.ts` NON intercetta /api/* (vedi il matcher in fondo a quel
* file), quindi qui non arriva nessuna protezione da monte: rate limit e
* controllo del segreto stanno tutti dentro la route.
* 2. Segreto assente in ambiente significa 403, non "passa". Le internal
* possono permetterselo perché sono raggiungibili solo da localhost; questa
* è esposta a internet, e un deploy con la variabile dimenticata deve
* smettere di accettare lead, non accettarli da chiunque.
*/
const MAX_BODY_BYTES = 16 * 1024;
function clientIp(request: NextRequest): string {
return (
request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
request.headers.get("x-real-ip") ||
"unknown"
);
}
export async function POST(request: NextRequest) {
const ip = clientIp(request);
// Il rate limit viene PRIMA del confronto sul segreto: altrimenti tentare
// segreti a raffica costerebbe zero.
if (!rateLimit(`webhook-lead:${ip}`, 20, 60 * 1000)) {
return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
}
const secret = process.env.LEAD_WEBHOOK_SECRET;
if (!secret) {
console.error("[webhook/lead] LEAD_WEBHOOK_SECRET non configurato — richiesta rifiutata");
return NextResponse.json({ error: "Non autorizzato" }, { status: 403 });
}
if (!safeEqual(request.headers.get("x-webhook-secret"), secret)) {
return NextResponse.json({ error: "Non autorizzato" }, { status: 403 });
}
const raw = await request.text();
if (raw.length > MAX_BODY_BYTES) {
return NextResponse.json({ error: "Payload troppo grande" }, { status: 413 });
}
let body: unknown;
try {
body = JSON.parse(raw);
} catch {
// I form che mandano application/x-www-form-urlencoded sono la norma:
// vale la pena leggerli invece di rispondere 400 e lasciare perdere il lead.
try {
body = Object.fromEntries(new URLSearchParams(raw));
} catch {
return NextResponse.json({ error: "Corpo non leggibile" }, { status: 400 });
}
}
const parsed = leadIntakeSchema.safeParse(normalizeLeadPayload(body));
if (!parsed.success) {
return NextResponse.json(
{ error: "Dati non validi", detail: parsed.error.issues[0].message },
{ status: 422 }
);
}
try {
const result = await ingestLead(parsed.data);
return NextResponse.json(
{ ok: true, outcome: result.outcome, leadId: result.leadId },
{ status: result.outcome === "created" ? 201 : 200 }
);
} catch (error) {
// Il dettaglio resta nei log del server: al chiamante non si dice mai
// perché il database si è lamentato.
console.error("[webhook/lead] ingest fallito:", error);
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}