diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts new file mode 100644 index 00000000..8e6f9013 --- /dev/null +++ b/app/api/leads/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postLeadsHandler } from "@/lib/leads/postLeadsHandler"; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} + +/** + * POST /api/leads + * + * Captures a marketing-site lead: stores it in Attio (person + triage note), + * then pages the admin Telegram chat with an Attio deep link — the api-side + * owner of marketing capture per the 2026-08-13 decision on + * recoupable/chat#1800, modeled on the valuation funnel's + * `captureValuationLead`. + * + * Unauthenticated by decision (chat#1800, 2026-08-12): the public forms + * feeding it make endpoint auth moot; revisit if spammed. + * + * Body: a discriminated union on `kind` — + * - `booking`: name + package required; company, role, rosterSize, message optional + * - `subscribe`: email + source; name, company, utm_*, audit_answers, + * audit_score, roi_inputs, roi_results optional + * + * Returns 200 `{ status, notified, record_url }` when stored (`notified` is + * false for internal test addresses, filtered by design), 400 on a bad body, + * and **502 when the lead could not be stored** — callers must surface it. + * + * @param request - The request object. + * @returns A NextResponse describing the capture outcome. + */ +export async function POST(request: NextRequest): Promise { + return postLeadsHandler(request); +} diff --git a/lib/leads/__tests__/buildAttioName.test.ts b/lib/leads/__tests__/buildAttioName.test.ts new file mode 100644 index 00000000..a5667f09 --- /dev/null +++ b/lib/leads/__tests__/buildAttioName.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { buildAttioName } from "@/lib/leads/buildAttioName"; + +// Ported from marketing#68 with its tests: Attio 400s on a name value missing +// full_name, and JSON.stringify drops undefined keys, so last_name must always +// be a string. recoupable/chat#1800. +describe("buildAttioName", () => { + it("sends full_name for a two-part name — Attio 400s without it", () => { + expect(buildAttioName("Ada Lovelace")).toEqual([ + { first_name: "Ada", last_name: "Lovelace", full_name: "Ada Lovelace" }, + ]); + }); + + it("sends a string last_name for a single-word name, never undefined", () => { + const value = buildAttioName("Prince"); + expect(value).toEqual([{ first_name: "Prince", last_name: "", full_name: "Prince" }]); + expect(value?.[0]).toHaveProperty("last_name"); + }); + + it("joins three or more parts into last_name and full_name", () => { + expect(buildAttioName("Ada King Lovelace")).toEqual([ + { first_name: "Ada", last_name: "King Lovelace", full_name: "Ada King Lovelace" }, + ]); + }); + + it("returns undefined for no name or a whitespace-only name", () => { + expect(buildAttioName(undefined)).toBeUndefined(); + expect(buildAttioName(" ")).toBeUndefined(); + }); +}); diff --git a/lib/leads/__tests__/buildLeadNote.test.ts b/lib/leads/__tests__/buildLeadNote.test.ts new file mode 100644 index 00000000..afadb30b --- /dev/null +++ b/lib/leads/__tests__/buildLeadNote.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { buildLeadNote } from "@/lib/leads/buildLeadNote"; + +describe("buildLeadNote", () => { + it("formats a booking as the Advisory Inquiry note the CRM is searched by", () => { + const note = buildLeadNote({ + kind: "booking", + email: "ada@example.com", + source: "/advisory/book", + name: "Ada Lovelace", + company: "Test Co", + package: "strategy-session", + role: "Label Owner / GM", + rosterSize: "21-50 artists", + message: "hello", + }); + expect(note?.title).toBe("Advisory Inquiry: Strategy Session ($2,500)"); + expect(note?.content).toContain("Package: Strategy Session ($2,500)"); + expect(note?.content).toContain("Company: Test Co"); + expect(note?.content).toContain("Role: Label Owner / GM"); + expect(note?.content).toContain("Roster Size: 21-50 artists"); + expect(note?.content).toContain("Message: hello"); + expect(note?.content).toContain("Source: /advisory/book"); + }); + + it("falls back to the raw package slug when the label is unknown", () => { + const note = buildLeadNote({ + kind: "booking", + email: "a@b.com", + source: "/advisory/book", + name: "Ada", + package: "mystery-tier", + }); + expect(note?.title).toBe("Advisory Inquiry: mystery-tier"); + }); + + it("formats a completed audit with score, answers and company", () => { + const note = buildLeadNote({ + kind: "subscribe", + email: "ada@example.com", + source: "/audit", + company: "Test Co", + audit_score: "Ready to Scale", + audit_answers: { role: "label-owner", budget: "5k-15k" }, + }); + expect(note?.title).toBe("AI Readiness Audit: Ready to Scale"); + expect(note?.content).toContain("Company: Test Co"); + expect(note?.content).toContain("role: label-owner"); + expect(note?.content).toContain("budget: 5k-15k"); + }); + + it("formats an ROI submission with inputs and results", () => { + const note = buildLeadNote({ + kind: "subscribe", + email: "ada@example.com", + source: "/roi", + company: "Test Co", + roi_inputs: { artists: 15 }, + roi_results: { yearlySavings: 93012 }, + }); + expect(note?.title).toBe("ROI Calculator"); + expect(note?.content).toContain("Company: Test Co"); + expect(note?.content).toContain("artists: 15"); + expect(note?.content).toContain("yearlySavings: 93012"); + }); + + it("returns null for a plain subscribe — a newsletter signup needs no note", () => { + expect(buildLeadNote({ kind: "subscribe", email: "a@b.com", source: "blog-cta" })).toBeNull(); + }); +}); diff --git a/lib/leads/__tests__/buildLeadNotification.test.ts b/lib/leads/__tests__/buildLeadNotification.test.ts new file mode 100644 index 00000000..e9d3d497 --- /dev/null +++ b/lib/leads/__tests__/buildLeadNotification.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { buildLeadNotification } from "@/lib/leads/buildLeadNotification"; + +const lead = { + email: "ada@example.com", + source: "/advisory/book", + name: "Ada Lovelace", + company: "Test Co", + role: "Label Owner / GM", + package: "Retained Advisor ($5,000/mo)", + rosterSize: "21-50 artists", +}; + +describe("buildLeadNotification", () => { + // #1800's acceptance criterion: the message must carry package, company and role. + it("carries package, company and role so the lead can be triaged from Telegram", () => { + const text = buildLeadNotification(lead); + expect(text).toContain("Package: Retained Advisor ($5,000/mo)"); + expect(text).toContain("Company: Test Co"); + expect(text).toContain("Role: Label Owner / GM"); + }); + + it("leads with the source so /advisory/book is distinguishable from /audit", () => { + expect(buildLeadNotification(lead).split("\n")[0]).toContain("/advisory/book"); + }); + + it("includes the name and email together", () => { + expect(buildLeadNotification(lead)).toContain("Ada Lovelace "); + }); + + it("falls back to the bare email when no name was supplied", () => { + const text = buildLeadNotification({ email: "ada@example.com", source: "/audit" }); + expect(text).toContain("ada@example.com"); + expect(text).not.toContain("<"); + }); + + it("omits absent optional fields rather than printing blanks", () => { + const text = buildLeadNotification({ email: "ada@example.com", source: "/audit" }); + expect(text).not.toContain("Company:"); + expect(text).not.toContain("Role:"); + expect(text).not.toContain("undefined"); + }); + + it("includes the free-text message when one was supplied", () => { + const text = buildLeadNotification({ ...lead, message: "We manage 30 artists" }); + expect(text).toContain("We manage 30 artists"); + }); +}); diff --git a/lib/leads/__tests__/captureLead.test.ts b/lib/leads/__tests__/captureLead.test.ts new file mode 100644 index 00000000..7ac00acb --- /dev/null +++ b/lib/leads/__tests__/captureLead.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { captureLead } from "@/lib/leads/captureLead"; +import { assertPersonByEmail } from "@/lib/attio/assertPersonByEmail"; +import { createNote } from "@/lib/attio/createNote"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +vi.mock("@/lib/attio/assertPersonByEmail", () => ({ + assertPersonByEmail: vi.fn().mockResolvedValue({ recordId: "rec-1" }), +})); +vi.mock("@/lib/attio/createNote", () => ({ + createNote: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ + sendSalesNotification: vi.fn().mockResolvedValue(undefined), +})); + +const booking = { + kind: "booking" as const, + email: "ada@example.com", + source: "/advisory/book", + name: "Ada Lovelace", + company: "Test Co", + package: "strategy-session", +}; + +describe("captureLead", () => { + beforeEach(() => { + vi.stubEnv("ATTIO_API_KEY", "test-key"); + vi.mocked(assertPersonByEmail).mockClear().mockResolvedValue({ recordId: "rec-1" }); + vi.mocked(createNote).mockClear(); + vi.mocked(sendSalesNotification).mockClear().mockResolvedValue(undefined); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("asserts the person with the full three-part name shape", async () => { + await captureLead(booking); + const values = vi.mocked(assertPersonByEmail).mock.calls[0][0]; + expect(values.email_addresses).toEqual([{ email_address: "ada@example.com" }]); + expect(values.name).toEqual([ + { first_name: "Ada", last_name: "Lovelace", full_name: "Ada Lovelace" }, + ]); + }); + + it("attaches the Advisory Inquiry note and pages Telegram with the Attio deep link", async () => { + const result = await captureLead(booking); + expect(result).toMatchObject({ success: true, notified: true }); + expect(vi.mocked(createNote).mock.calls[0][0]).toMatchObject({ + parentObject: "people", + parentRecordId: "rec-1", + title: "Advisory Inquiry: Strategy Session ($2,500)", + }); + const [{ email, text }] = vi.mocked(sendSalesNotification).mock.calls[0]; + expect(email).toBe("ada@example.com"); + expect(text).toContain("/advisory/book"); + expect(text).toContain("Package: Strategy Session ($2,500)"); + expect(text).toContain("https://app.attio.com/recoup/person/rec-1/overview"); + }); + + it("creates no note for a plain subscribe", async () => { + await captureLead({ kind: "subscribe", email: "a@b.com", source: "blog-cta" }); + expect(createNote).not.toHaveBeenCalled(); + expect(sendSalesNotification).toHaveBeenCalled(); + }); + + // The lead was NOT stored — this must fail loudly, not page a human about a + // lead that does not exist. chat#1800's core architecture decision. + it("fails loudly on an Attio failure: no note, no page, error returned", async () => { + vi.mocked(assertPersonByEmail).mockResolvedValueOnce({ error: "assert failed: 400" }); + const result = await captureLead(booking); + expect(result.success).toBe(false); + expect(createNote).not.toHaveBeenCalled(); + expect(sendSalesNotification).not.toHaveBeenCalled(); + }); + + it("reports notified:false for a test address so verification is assertable over HTTP", async () => { + const result = await captureLead({ ...booking, email: "sweetmantech@gmail.com" }); + expect(result).toMatchObject({ success: true, notified: false }); + }); + + // The lead is already in Attio by this point — a Telegram outage must not + // turn a stored lead into a visitor-facing error. + it("still succeeds when the notifier rejects", async () => { + vi.mocked(sendSalesNotification).mockRejectedValueOnce(new Error("telegram down")); + const result = await captureLead(booking); + expect(result.success).toBe(true); + }); + + it("fails when ATTIO_API_KEY is not configured — misconfiguration, not silence", async () => { + vi.stubEnv("ATTIO_API_KEY", ""); + const result = await captureLead(booking); + expect(result.success).toBe(false); + expect(assertPersonByEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/leads/__tests__/postLeadsHandler.test.ts b/lib/leads/__tests__/postLeadsHandler.test.ts new file mode 100644 index 00000000..cd6b4da8 --- /dev/null +++ b/lib/leads/__tests__/postLeadsHandler.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { postLeadsHandler } from "@/lib/leads/postLeadsHandler"; +import { captureLead } from "@/lib/leads/captureLead"; + +vi.mock("@/lib/leads/captureLead", () => ({ + captureLead: vi + .fn() + .mockResolvedValue({ success: true, notified: true, recordUrl: "https://app.attio.com/x" }), +})); + +const post = (body: unknown) => + new NextRequest("https://api.recoupable.dev/api/leads", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +const booking = { + kind: "booking", + email: "ada@example.com", + source: "/advisory/book", + name: "Ada Lovelace", + package: "strategy-session", +}; + +describe("postLeadsHandler", () => { + beforeEach(() => { + vi.mocked(captureLead) + .mockClear() + .mockResolvedValue({ success: true, notified: true, recordUrl: "https://app.attio.com/x" }); + }); + + it("200s with notified and the record url when the lead is stored", async () => { + const response = await postLeadsHandler(post(booking)); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + status: "success", + notified: true, + record_url: "https://app.attio.com/x", + }); + }); + + // Log-and-return-success is the root cause this whole issue exists to end. + it("502s when the lead was NOT stored — never a fake success", async () => { + vi.mocked(captureLead).mockResolvedValueOnce({ success: false, error: "assert failed" }); + const response = await postLeadsHandler(post(booking)); + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ status: "error" }); + }); + + it("never echoes the upstream Attio error to the visitor", async () => { + vi.mocked(captureLead).mockResolvedValueOnce({ + success: false, + error: "assert failed: 400 — secret internals", + }); + const response = await postLeadsHandler(post(booking)); + expect(JSON.stringify(await response.json())).not.toContain("secret internals"); + }); + + it("400s on an invalid body without calling capture", async () => { + const response = await postLeadsHandler(post({ kind: "booking", email: "nope" })); + expect(response.status).toBe(400); + expect(captureLead).not.toHaveBeenCalled(); + }); + + it("400s on a non-JSON body rather than throwing", async () => { + const request = new NextRequest("https://api.recoupable.dev/api/leads", { + method: "POST", + body: "not json", + }); + expect((await postLeadsHandler(request)).status).toBe(400); + }); +}); diff --git a/lib/leads/__tests__/validatePostLeadsBody.test.ts b/lib/leads/__tests__/validatePostLeadsBody.test.ts new file mode 100644 index 00000000..4778b114 --- /dev/null +++ b/lib/leads/__tests__/validatePostLeadsBody.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validatePostLeadsBody } from "@/lib/leads/validatePostLeadsBody"; + +const booking = { + kind: "booking", + email: "ada@example.com", + source: "/advisory/book", + name: "Ada Lovelace", + company: "Test Co", + package: "strategy-session", + role: "Label Owner / GM", + rosterSize: "21-50 artists", + message: "hello", +}; + +const subscribe = { + kind: "subscribe", + email: "ada@example.com", + source: "/audit", +}; + +describe("validatePostLeadsBody", () => { + it("accepts a full booking", () => { + expect(validatePostLeadsBody(booking)).toMatchObject({ kind: "booking" }); + }); + + it("rejects a booking without a package — the triage field", () => { + const result = validatePostLeadsBody({ ...booking, package: undefined }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("rejects a booking without a name", () => { + const result = validatePostLeadsBody({ ...booking, name: undefined }); + expect((result as NextResponse).status).toBe(400); + }); + + it("accepts a minimal subscribe (email + source)", () => { + expect(validatePostLeadsBody(subscribe)).toMatchObject({ kind: "subscribe" }); + }); + + it("accepts and preserves the audit qualifying fields", () => { + const result = validatePostLeadsBody({ + ...subscribe, + name: "Ada Lovelace", + company: "Test Co", + audit_answers: { role: "label-owner", budget: "5k-15k" }, + audit_score: "Ready to Scale", + }); + expect(result).toMatchObject({ + audit_answers: { role: "label-owner", budget: "5k-15k" }, + audit_score: "Ready to Scale", + }); + }); + + it("accepts and preserves the ROI qualifying fields", () => { + const result = validatePostLeadsBody({ + ...subscribe, + source: "/roi", + company: "Test Co", + roi_inputs: { artists: 15, contentSpend: 5000 }, + roi_results: { yearlySavings: 93012 }, + }); + expect(result).toMatchObject({ + roi_inputs: { artists: 15, contentSpend: 5000 }, + roi_results: { yearlySavings: 93012 }, + }); + }); + + it("rejects an unknown kind", () => { + expect((validatePostLeadsBody({ ...subscribe, kind: "nope" }) as NextResponse).status).toBe( + 400, + ); + }); + + it("rejects a bad email", () => { + expect((validatePostLeadsBody({ ...subscribe, email: "nope" }) as NextResponse).status).toBe( + 400, + ); + }); + + it("rejects a missing source — an unattributable lead cannot be triaged", () => { + expect( + (validatePostLeadsBody({ kind: "subscribe", email: "a@b.com" }) as NextResponse).status, + ).toBe(400); + }); + + it("rejects a non-object body", () => { + expect((validatePostLeadsBody("nope") as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/leads/buildAttioName.ts b/lib/leads/buildAttioName.ts new file mode 100644 index 00000000..e1e98e90 --- /dev/null +++ b/lib/leads/buildAttioName.ts @@ -0,0 +1,30 @@ +/** Attio's `name` attribute value — all three parts are required. */ +export interface AttioNameValue { + first_name: string; + last_name: string; + full_name: string; +} + +/** + * Builds the `name` attribute value Attio accepts. Ported from marketing#68. + * + * Attio rejects a partial name with `400 — invalid value for attribute "name"`, + * so `full_name` is mandatory and `last_name` must be a string rather than + * `undefined` (JSON.stringify drops undefined keys, which reproduces the 400). + * + * @param name - Free-text name as typed into a form, if any. + * @returns The values array, or undefined when there is no name to send. + */ +export function buildAttioName(name?: string): AttioNameValue[] | undefined { + const parts = name?.trim().split(/\s+/).filter(Boolean) ?? []; + if (parts.length === 0) return undefined; + + const [first, ...rest] = parts; + return [ + { + first_name: first, + last_name: rest.join(" "), + full_name: parts.join(" "), + }, + ]; +} diff --git a/lib/leads/buildLeadNote.ts b/lib/leads/buildLeadNote.ts new file mode 100644 index 00000000..a9a83dc8 --- /dev/null +++ b/lib/leads/buildLeadNote.ts @@ -0,0 +1,69 @@ +import type { PostLeadsBody } from "@/lib/leads/validatePostLeadsBody"; +import { packageLabel } from "@/lib/leads/packageLabel"; + +/** Formats a Record payload as "key: value" lines for a note body. */ +function recordLines(record: Record | undefined): string[] { + return Object.entries(record ?? {}).map(([key, value]) => `${key}: ${String(value)}`); +} + +/** + * Formats a captured lead as the Attio note a human actually reads, or null + * when there is nothing worth a note (a plain newsletter signup). + * + * The "Advisory Inquiry" title prefix is what the CRM is searched by, so it + * must stay stable — ported from marketing#68 (recoupable/chat#1800). The + * audit and ROI notes carry the qualifying payloads marketing's schema used to + * strip (superseded marketing#71). + * + * @param lead - The validated lead. + * @returns The note title and content, or null when no note applies. + */ +export function buildLeadNote(lead: PostLeadsBody): { title: string; content: string } | null { + if (lead.kind === "booking") { + const label = packageLabel(lead.package); + const content = [ + `📅 Advisory Booking Request`, + `Package: ${label}`, + lead.company && `Company: ${lead.company}`, + lead.role && `Role: ${lead.role}`, + lead.rosterSize && `Roster Size: ${lead.rosterSize}`, + lead.message && `Message: ${lead.message}`, + `Source: ${lead.source}`, + ] + .filter(Boolean) + .join("\n"); + return { title: `Advisory Inquiry: ${label}`, content }; + } + + if (lead.audit_score !== undefined || lead.audit_answers) { + const content = [ + `🧮 AI Readiness Audit`, + lead.audit_score !== undefined && `Score: ${lead.audit_score}`, + lead.company && `Company: ${lead.company}`, + ...recordLines(lead.audit_answers), + `Source: ${lead.source}`, + ] + .filter(Boolean) + .join("\n"); + const title = + lead.audit_score !== undefined + ? `AI Readiness Audit: ${lead.audit_score}` + : "AI Readiness Audit"; + return { title, content }; + } + + if (lead.roi_inputs || lead.roi_results) { + const content = [ + `📈 ROI Calculator`, + lead.company && `Company: ${lead.company}`, + ...recordLines(lead.roi_inputs), + ...recordLines(lead.roi_results), + `Source: ${lead.source}`, + ] + .filter(Boolean) + .join("\n"); + return { title: "ROI Calculator", content }; + } + + return null; +} diff --git a/lib/leads/buildLeadNotification.ts b/lib/leads/buildLeadNotification.ts new file mode 100644 index 00000000..916e2bb2 --- /dev/null +++ b/lib/leads/buildLeadNotification.ts @@ -0,0 +1,35 @@ +/** A captured marketing lead, already validated by the route. */ +export interface LeadNotificationInput { + email: string; + source: string; + name?: string; + company?: string; + role?: string; + package?: string; + rosterSize?: string; + message?: string; +} + +/** + * Formats a captured lead as the Telegram message a human reads. + * + * Package, company and role are the triage fields — a $5,000/mo retained + * advisory enquiry and a newsletter signup must be distinguishable without + * opening the CRM (recoupable/chat#1800). + * + * @param lead - The captured lead. + * @returns The message body for sendSalesNotification. + */ +export function buildLeadNotification(lead: LeadNotificationInput): string { + return [ + `🎯 New lead — ${lead.source}`, + lead.name ? `${lead.name} <${lead.email}>` : lead.email, + lead.package && `Package: ${lead.package}`, + lead.company && `Company: ${lead.company}`, + lead.role && `Role: ${lead.role}`, + lead.rosterSize && `Roster: ${lead.rosterSize}`, + lead.message && `Message: ${lead.message}`, + ] + .filter(Boolean) + .join("\n"); +} diff --git a/lib/leads/captureLead.ts b/lib/leads/captureLead.ts new file mode 100644 index 00000000..d45f5083 --- /dev/null +++ b/lib/leads/captureLead.ts @@ -0,0 +1,71 @@ +import { assertPersonByEmail } from "@/lib/attio/assertPersonByEmail"; +import { createNote } from "@/lib/attio/createNote"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; +import { isTestEmail } from "@/lib/emails/isTestEmail"; +import { buildAttioName } from "@/lib/leads/buildAttioName"; +import { buildLeadNote } from "@/lib/leads/buildLeadNote"; +import { buildLeadNotification } from "@/lib/leads/buildLeadNotification"; +import { packageLabel } from "@/lib/leads/packageLabel"; +import type { PostLeadsBody } from "@/lib/leads/validatePostLeadsBody"; + +const ATTIO_WORKSPACE = "recoup"; + +export type CaptureLeadResult = + | { success: true; notified: boolean; recordUrl?: string } + | { success: false; error: string }; + +/** + * Capture a marketing-site lead: store it in Attio, attach the triage note, + * and page a human on Telegram — the server-side owner of the flow, modeled on + * `captureValuationLead` (recoupable/chat#1800). + * + * Storage is the success criterion and **fails loudly**: an Attio failure + * returns an error (the route turns it into a 502) and pages nobody — a + * notification about a lead that was not stored would be a false alarm. The + * note and the Telegram ping are best-effort once the person exists. + * + * The package labels a $5,000/mo enquiry; buildLeadNotification carries the + * triage fields and the Attio deep link so the channel can open the lead in + * one tap. `notified` mirrors the `isTestEmail` filter so verification is + * assertable over HTTP instead of by watching the channel. + * + * @param lead - The validated lead. + * @returns The capture outcome. + */ +export async function captureLead(lead: PostLeadsBody): Promise { + if (!process.env.ATTIO_API_KEY) { + return { success: false, error: "ATTIO_API_KEY not configured" }; + } + + const name = buildAttioName(lead.name); + const { recordId, error } = await assertPersonByEmail({ + email_addresses: [{ email_address: lead.email }], + ...(name && { name }), + }); + if (error) return { success: false, error }; + + const recordUrl = recordId + ? `https://app.attio.com/${ATTIO_WORKSPACE}/person/${recordId}/overview` + : undefined; + + const note = buildLeadNote(lead); + if (note && recordId) { + await createNote({ + parentObject: "people", + parentRecordId: recordId, + title: note.title, + content: note.content, + }); + } + + // sendSalesNotification applies the isTestEmail filter itself and never + // throws; the local read is what makes `notified` assertable over HTTP. + const notified = !isTestEmail(lead.email); + const labeled = lead.kind === "booking" ? { ...lead, package: packageLabel(lead.package) } : lead; + const text = buildLeadNotification(labeled) + (recordUrl ? `\nAttio: ${recordUrl}` : ""); + await sendSalesNotification({ email: lead.email, text }).catch(err => { + console.error("[leads] notifier failed:", err); + }); + + return { success: true, notified, recordUrl }; +} diff --git a/lib/leads/packageLabel.ts b/lib/leads/packageLabel.ts new file mode 100644 index 00000000..82d1b7d2 --- /dev/null +++ b/lib/leads/packageLabel.ts @@ -0,0 +1,17 @@ +const PACKAGE_LABELS: Record = { + "strategy-session": "Strategy Session ($2,500)", + "ai-transformation": "AI Transformation ($10,000)", + "retained-advisor": "Retained Advisor ($5,000/mo)", +}; + +/** + * Maps an advisory package slug to the human label used in the Attio note + * title and the Telegram ping. Falls back to the raw slug so an unknown + * package is still triageable rather than blank. + * + * @param slug - The package slug the form submitted. + * @returns The display label. + */ +export function packageLabel(slug: string): string { + return PACKAGE_LABELS[slug] || slug; +} diff --git a/lib/leads/postLeadsHandler.ts b/lib/leads/postLeadsHandler.ts new file mode 100644 index 00000000..60a66d25 --- /dev/null +++ b/lib/leads/postLeadsHandler.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validatePostLeadsBody } from "@/lib/leads/validatePostLeadsBody"; +import { captureLead } from "@/lib/leads/captureLead"; + +/** + * Handler for POST /api/leads. + * + * A non-200 here means the lead was NOT stored — log-and-return-success is the + * root cause chat#1800 exists to end, so an Attio failure is a 502 the caller + * must surface, never a fake success. The upstream error detail is logged, not + * echoed: the visitor gets a generic message, the operator gets the log line. + * + * @param request - The incoming request + * @returns 200 with `{ status, notified, record_url }`, 400 on a bad body, or + * 502 when the lead could not be stored. + */ +export async function postLeadsHandler(request: NextRequest): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validatePostLeadsBody(body); + if (validated instanceof NextResponse) return validated; + + const result = await captureLead(validated); + if (result.success === false) { + console.error("[leads] capture failed:", result.error); + return NextResponse.json( + { status: "error", error: "We could not save this lead. Please try again." }, + { status: 502, headers: getCorsHeaders() }, + ); + } + + return NextResponse.json( + { status: "success", notified: result.notified, record_url: result.recordUrl }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/leads/validatePostLeadsBody.ts b/lib/leads/validatePostLeadsBody.ts new file mode 100644 index 00000000..bdb788b8 --- /dev/null +++ b/lib/leads/validatePostLeadsBody.ts @@ -0,0 +1,71 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +// Shared by every capture surface. `source` is required rather than optional: +// an unattributable lead cannot be triaged, which is the whole point of +// capturing it (recoupable/chat#1800). +const commonFields = { + email: z.string().email("email must be a valid email address"), + source: z.string().min(1, "source is required"), + company: z.string().optional(), +}; + +const bookingSchema = z.object({ + kind: z.literal("booking"), + ...commonFields, + // The booking form requires a name and a package — the two fields that make + // an advisory enquiry actionable. + name: z.string().min(1, "name is required"), + package: z.string().min(1, "package is required"), + role: z.string().optional(), + rosterSize: z.string().optional(), + message: z.string().optional(), +}); + +const subscribeSchema = z.object({ + kind: z.literal("subscribe"), + ...commonFields, + name: z.string().optional(), + utm_source: z.string().optional(), + utm_medium: z.string().optional(), + utm_campaign: z.string().optional(), + source_post_slug: z.string().optional(), + // The qualifying payloads previously stripped by marketing's schema + // (chat#1800, superseded marketing#71) — a completed audit is the most + // qualified lead the marketing site produces. + audit_answers: z.record(z.string(), z.unknown()).optional(), + audit_score: z.union([z.string(), z.number()]).optional(), + roi_inputs: z.record(z.string(), z.unknown()).optional(), + roi_results: z.record(z.string(), z.unknown()).optional(), +}); + +export const postLeadsBodySchema = z.discriminatedUnion("kind", [bookingSchema, subscribeSchema]); + +export type PostLeadsBody = z.infer; +export type BookingLead = z.infer; +export type SubscribeLead = z.infer; + +/** + * Validates the request body for POST /api/leads. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body. + */ +export function validatePostLeadsBody(body: unknown): NextResponse | PostLeadsBody { + const result = postLeadsBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +}