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>
This commit is contained in:
2026-08-19 22:57:42 +02:00
parent d6d3be00f4
commit 19ed377214
3 changed files with 282 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
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 });
}
}
+183
View File
@@ -0,0 +1,183 @@
import { db } from "@/db";
import { leads, activities } from "@/db/schema";
import { and, eq, sql } from "drizzle-orm";
import { z } from "zod";
/**
* Ingresso lead dall'esterno: form del sito, TidyCal, o qualunque cosa sappia
* fare un POST.
*
* La normalizzazione sta qui e non nella route perché è la parte che cambia
* quando si aggiunge una sorgente. La route resta la guardia: segreto, rate
* limit, forma della risposta.
*/
/** Forma canonica, dopo la normalizzazione. */
export const leadIntakeSchema = z.object({
name: z.string().trim().min(1, "Nome richiesto").max(100),
email: z.string().trim().email("Email non valida").optional(),
phone: z.string().trim().max(50).optional(),
company: z.string().trim().max(120).optional(),
notes: z.string().trim().max(2000).optional(),
/** Da dove arriva — finisce nelle note, per sapere cosa sta portando lead. */
source: z.string().trim().max(60).optional(),
/** Data della call, quando la sorgente è un calendario. */
meetingAt: z.coerce.date().optional(),
/** Id lato sorgente, per non creare due volte lo stesso lead. */
externalRef: z.string().trim().max(120).optional(),
});
export type LeadIntake = z.infer<typeof leadIntakeSchema>;
// Sinonimi accettati per ciascun campo canonico. Le sorgenti chiamano le stesse
// cose in modi diversi — un form Elementor in italiano manda "nome" e
// "messaggio" — e riscrivere la route per ognuna sarebbe il modo sbagliato.
const ALIASES: Record<keyof LeadIntake, string[]> = {
name: ["name", "nome", "full_name", "fullname", "nome_completo", "your-name"],
email: ["email", "mail", "e-mail", "email_address", "your-email"],
phone: ["phone", "telefono", "tel", "cellulare", "phone_number"],
company: ["company", "azienda", "società", "societa", "business", "brand"],
notes: ["notes", "note", "message", "messaggio", "note_aggiuntive", "comments"],
source: ["source", "sorgente", "form_name", "origine", "utm_source"],
meetingAt: ["meetingat", "meeting_at", "booking_date", "starts_at", "start_time", "data"],
externalRef: ["externalref", "external_ref", "id", "booking_id"],
};
/** Estrae il valore utile da una voce che può essere scalare o {value: …}. */
function unwrap(value: unknown): string | undefined {
if (value === null || value === undefined) return undefined;
if (typeof value === "string" || typeof value === "number") return String(value);
if (typeof value === "object") {
const obj = value as Record<string, unknown>;
// Elementor manda ogni campo come { id, title, value, raw_value }
for (const key of ["value", "raw_value", "text"]) {
if (typeof obj[key] === "string" || typeof obj[key] === "number") {
return String(obj[key]);
}
}
}
return undefined;
}
/**
* Riduce un payload qualunque alla forma canonica.
*
* Regge tre casi senza saperli distinguere: piatto (`{name, email}`), annidato
* in `fields` (Elementor Pro), e con i campi come oggetti `{value: …}`.
*
* NOTA: la forma esatta del payload di Elementor Pro va confermata con un invio
* vero prima di considerarla verificata. Qui è gestita in modo difensivo, non
* su documentazione letta.
*/
export function normalizeLeadPayload(body: unknown): Record<string, unknown> {
if (typeof body !== "object" || body === null) return {};
const raw = body as Record<string, unknown>;
// Appiattisce: prima il livello alto, poi `fields`/`data` che vincono perché
// più specifici.
const flat: Record<string, unknown> = {};
const merge = (source: unknown) => {
if (typeof source !== "object" || source === null) return;
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
const unwrapped = unwrap(value);
if (unwrapped !== undefined && unwrapped !== "") {
flat[key.toLowerCase().trim()] = unwrapped;
}
}
};
merge(raw);
merge(raw.fields);
merge(raw.data);
const out: Record<string, unknown> = {};
for (const [canonical, aliases] of Object.entries(ALIASES)) {
for (const alias of aliases) {
if (flat[alias] !== undefined) {
out[canonical] = flat[alias];
break;
}
}
}
return out;
}
export type IngestResult =
| { outcome: "created"; leadId: string }
| { outcome: "updated"; leadId: string };
/**
* Crea il lead, oppure aggiorna quello che c'è già.
*
* Chi compila due volte il form non deve diventare due lead: la pipeline si
* riempirebbe di doppioni proprio per i contatti più interessati. Il secondo
* invio aggiorna `last_contact_date` e lascia un'attività con quello che ha
* scritto, così il messaggio non si perde ma la scheda resta una.
*
* Il riconoscimento è sull'email. Senza email non si può dedurre, e si crea.
*/
export async function ingestLead(input: LeadIntake): Promise<IngestResult> {
const now = new Date();
const contextLines = [
input.source ? `Origine: ${input.source}` : null,
input.meetingAt ? `Call fissata: ${input.meetingAt.toLocaleString("it-IT")}` : null,
input.notes ?? null,
].filter(Boolean);
const body = contextLines.join("\n");
if (input.email) {
const existing = await db
.select({ id: leads.id })
.from(leads)
.where(and(eq(leads.archived, false), sql`lower(${leads.email}) = lower(${input.email})`))
.limit(1);
if (existing[0]) {
const leadId = existing[0].id;
await db
.update(leads)
.set({
last_contact_date: now,
updated_at: now,
...(input.meetingAt ? { next_action_date: input.meetingAt } : {}),
...(input.phone ? { phone: input.phone } : {}),
...(input.company ? { company: input.company } : {}),
})
.where(eq(leads.id, leadId));
await db.insert(activities).values({
lead_id: leadId,
type: input.meetingAt ? "meeting" : "note",
activity_date: input.meetingAt ?? now,
notes: body || "Nuovo invio dal form, senza messaggio.",
});
return { outcome: "updated", leadId };
}
}
const [lead] = await db
.insert(leads)
.values({
name: input.name,
email: input.email ?? null,
phone: input.phone ?? null,
company: input.company ?? null,
status: "contacted",
notes: body || null,
last_contact_date: now,
next_action: input.meetingAt ? "Call fissata" : null,
next_action_date: input.meetingAt ?? null,
})
.returning({ id: leads.id });
if (input.meetingAt) {
await db.insert(activities).values({
lead_id: lead.id,
type: "meeting",
activity_date: input.meetingAt,
notes: body || "Call prenotata.",
});
}
return { outcome: "created", leadId: lead.id };
}