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:
@@ -15,3 +15,10 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
|
|||||||
# RESEND_FROM deve usare un dominio verificato su Resend
|
# RESEND_FROM deve usare un dominio verificato su Resend
|
||||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
||||||
|
|
||||||
|
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
|
||||||
|
# POST /api/webhooks/lead, header x-webhook-secret.
|
||||||
|
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
|
||||||
|
# variabile manca, la route risponde 403 invece di lasciar passare.
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
LEAD_WEBHOOK_SECRET=generate-with-openssl-rand-base64-32
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user