Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 10
feat(leads): POST /api/leads — capture leads in api (Attio person + note + Telegram deep link)#825
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
72349219c403ba4366cd65bdfc9e5fdd6420fbf135File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: The booking contract omits required Prompt for AI agents | ||
| * - `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<NextResponse> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Because this route accepts unauthenticated POSTs without an abuse control, anyone can repeatedly submit valid lead bodies to pollute Attio and page the admin Telegram chat. Add server-side rate limiting and/or bot verification before invoking Prompt for AI agents | ||
| return postLeadsHandler(request); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <ada@example.com>"); | ||
| }); | ||
| 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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
Repository: recoupable/api
Length of output: 50371
🏁 Script executed:
Repository: recoupable/api
Length of output: 20814
🏁 Script executed:
Repository: recoupable/api
Length of output: 12018
🏁 Script executed:
Repository: recoupable/api
Length of output: 10001
🏁 Script executed:
Repository: recoupable/api
Length of output: 263
Protect the public lead-ingestion path before merge.
POST /api/leadshas novalidateAuthContext()or anti-abuse control. Any caller can submit a valid lead and trigger Attio and Telegram side effects. Require authentication, or document the approved public exception and add rate limiting and bot protection.🤖 Prompt for AI Agents
Sources: Coding guidelines, Path instructions