From 19ed377214e2c9cd57e489fe40bb8b66b5b01495 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Wed, 19 Aug 2026 22:57:42 +0200 Subject: [PATCH] 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 --- .env.example | 7 ++ src/app/api/webhooks/lead/route.ts | 92 +++++++++++++++ src/lib/lead-intake.ts | 183 +++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 src/app/api/webhooks/lead/route.ts create mode 100644 src/lib/lead-intake.ts diff --git a/.env.example b/.env.example index 363a8dc..e753162 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,10 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32 # RESEND_FROM deve usare un dominio verificato su Resend RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx RESEND_FROM=Nome Mittente + +# 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 diff --git a/src/app/api/webhooks/lead/route.ts b/src/app/api/webhooks/lead/route.ts new file mode 100644 index 0000000..2dea772 --- /dev/null +++ b/src/app/api/webhooks/lead/route.ts @@ -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 }); + } +} diff --git a/src/lib/lead-intake.ts b/src/lib/lead-intake.ts new file mode 100644 index 0000000..4cd9765 --- /dev/null +++ b/src/lib/lead-intake.ts @@ -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; + +// 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 = { + 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; + // 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 { + if (typeof body !== "object" || body === null) return {}; + const raw = body as Record; + + // Appiattisce: prima il livello alto, poi `fields`/`data` che vincono perché + // più specifici. + const flat: Record = {}; + const merge = (source: unknown) => { + if (typeof source !== "object" || source === null) return; + for (const [key, value] of Object.entries(source as Record)) { + const unwrapped = unwrap(value); + if (unwrapped !== undefined && unwrapped !== "") { + flat[key.toLowerCase().trim()] = unwrapped; + } + } + }; + merge(raw); + merge(raw.fields); + merge(raw.data); + + const out: Record = {}; + 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 { + 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 }; +}