From 72349212401644f1d42dd7a4fb96b06251b8e80b Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Sat, 8 Aug 2026 12:29:09 -0500 Subject: [PATCH 1/5] feat(notifications): POST /api/notifications/lead pages a human on capture sendSalesNotification already runs in production for eight Stripe callers, already filters internal addresses, and already never throws. It was simply unreachable over HTTP, so no marketing-site capture has ever announced itself. This exposes it. - validateInternalRequest: bearer INTERNAL_API_SECRET, mirroring validateCronRequest including its fail-closed 500 on an unset secret. Kept separate because these callers are not Vercel Cron and must not share CRON_SECRET. - buildLeadNotification: package, company and role are the triage fields, so a $5,000/mo advisory enquiry is distinguishable from a newsletter signup without opening the CRM - postLeadNotificationHandler: 200s once the body is valid, since the lead is already in Attio by then and a Telegram outage must not report the capture as failed. Responds `notified: false` for internal test addresses so that case is assertable over HTTP rather than by watching a channel. Implements item 3 of recoupable/chat#1800. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/notifications/lead/route.ts | 38 ++++++++ .../__tests__/validateInternalRequest.test.ts | 47 ++++++++++ lib/internal/validateInternalRequest.ts | 30 ++++++ .../__tests__/buildLeadNotification.test.ts | 48 ++++++++++ .../postLeadNotificationHandler.test.ts | 94 +++++++++++++++++++ .../__tests__/validatePostLeadBody.test.ts | 40 ++++++++ lib/notifications/buildLeadNotification.ts | 35 +++++++ .../postLeadNotificationHandler.ts | 59 ++++++++++++ lib/notifications/validatePostLeadBody.ts | 44 +++++++++ 9 files changed, 435 insertions(+) create mode 100644 app/api/notifications/lead/route.ts create mode 100644 lib/internal/__tests__/validateInternalRequest.test.ts create mode 100644 lib/internal/validateInternalRequest.ts create mode 100644 lib/notifications/__tests__/buildLeadNotification.test.ts create mode 100644 lib/notifications/__tests__/postLeadNotificationHandler.test.ts create mode 100644 lib/notifications/__tests__/validatePostLeadBody.test.ts create mode 100644 lib/notifications/buildLeadNotification.ts create mode 100644 lib/notifications/postLeadNotificationHandler.ts create mode 100644 lib/notifications/validatePostLeadBody.ts diff --git a/app/api/notifications/lead/route.ts b/app/api/notifications/lead/route.ts new file mode 100644 index 000000000..ddff744c2 --- /dev/null +++ b/app/api/notifications/lead/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { postLeadNotificationHandler } from "@/lib/notifications/postLeadNotificationHandler"; + +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/notifications/lead + * + * Pages the admin Telegram chat when the marketing site captures a lead. + * Internal, server-to-server only — requires + * `Authorization: Bearer ${INTERNAL_API_SECRET}`. + * + * Body parameters: + * - email (required): the lead's email address + * - source (required): the capturing surface, e.g. `/advisory/book` + * - name, company, role, package, rosterSize, message (optional): triage fields + * + * Returns `{ status, notified }` — `notified` is false for internal test + * addresses, which are filtered out of the channel by design. + * + * @param request - The request object. + * @returns A NextResponse describing whether a message was sent. + */ +export async function POST(request: NextRequest): Promise { + return postLeadNotificationHandler(request); +} diff --git a/lib/internal/__tests__/validateInternalRequest.test.ts b/lib/internal/__tests__/validateInternalRequest.test.ts new file mode 100644 index 000000000..16ac4f99b --- /dev/null +++ b/lib/internal/__tests__/validateInternalRequest.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { validateInternalRequest } from "@/lib/internal/validateInternalRequest"; + +const requestWith = (authorization?: string) => + new NextRequest("https://api.recoupable.dev/api/notifications/lead", { + method: "POST", + headers: authorization ? { authorization } : {}, + }); + +describe("validateInternalRequest", () => { + const original = process.env.INTERNAL_API_SECRET; + + beforeEach(() => { + process.env.INTERNAL_API_SECRET = "s3cr3t"; + }); + afterEach(() => { + if (original === undefined) delete process.env.INTERNAL_API_SECRET; + else process.env.INTERNAL_API_SECRET = original; + }); + + it("returns null when the bearer token matches", () => { + expect(validateInternalRequest(requestWith("Bearer s3cr3t"))).toBeNull(); + }); + + it("401s when the token does not match", async () => { + const denied = validateInternalRequest(requestWith("Bearer wrong")); + expect(denied?.status).toBe(401); + }); + + it("401s when the header is absent", () => { + expect(validateInternalRequest(requestWith())?.status).toBe(401); + }); + + // A missing secret must not become an open door — an unconfigured deployment + // should fail closed and loudly, exactly as validateCronRequest does. + it("500s when INTERNAL_API_SECRET is unset (misconfiguration, not open door)", () => { + delete process.env.INTERNAL_API_SECRET; + expect(validateInternalRequest(requestWith("Bearer anything"))?.status).toBe(500); + }); + + it("never echoes the configured secret in the response body", async () => { + const denied = validateInternalRequest(requestWith("Bearer wrong")); + const body = await denied!.json(); + expect(JSON.stringify(body)).not.toContain("s3cr3t"); + }); +}); diff --git a/lib/internal/validateInternalRequest.ts b/lib/internal/validateInternalRequest.ts new file mode 100644 index 000000000..b200e0456 --- /dev/null +++ b/lib/internal/validateInternalRequest.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; + +/** + * Gates an internal route to trusted server-to-server callers — currently the + * marketing site posting lead notifications. Callers send + * `Authorization: Bearer ${INTERNAL_API_SECRET}`; we require an exact match. + * + * Mirrors `validateCronRequest`, including its stance that a missing secret is + * a misconfiguration (500) rather than an open door. Kept separate from it + * because these callers are not Vercel Cron and must not share CRON_SECRET. + * + * @param request - The incoming Next.js request. + * @returns A NextResponse to short-circuit on failure, or null when authorized. + */ +export function validateInternalRequest(request: NextRequest): NextResponse | null { + const secret = process.env.INTERNAL_API_SECRET; + if (!secret) { + console.error("[internal] INTERNAL_API_SECRET is not configured"); + return NextResponse.json( + { status: "error", message: "Internal server error" }, + { status: 500 }, + ); + } + + if (request.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ status: "error", message: "Unauthorized" }, { status: 401 }); + } + + return null; +} diff --git a/lib/notifications/__tests__/buildLeadNotification.test.ts b/lib/notifications/__tests__/buildLeadNotification.test.ts new file mode 100644 index 000000000..146aec770 --- /dev/null +++ b/lib/notifications/__tests__/buildLeadNotification.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { buildLeadNotification } from "@/lib/notifications/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/notifications/__tests__/postLeadNotificationHandler.test.ts b/lib/notifications/__tests__/postLeadNotificationHandler.test.ts new file mode 100644 index 000000000..43a8023c5 --- /dev/null +++ b/lib/notifications/__tests__/postLeadNotificationHandler.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; +import { postLeadNotificationHandler } from "@/lib/notifications/postLeadNotificationHandler"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; + +vi.mock("@/lib/telegram/sendSalesNotification", () => ({ + sendSalesNotification: vi.fn().mockResolvedValue(undefined), +})); + +const post = (body: unknown, authorization = "Bearer s3cr3t") => + new NextRequest("https://api.recoupable.dev/api/notifications/lead", { + method: "POST", + headers: { authorization, "content-type": "application/json" }, + body: JSON.stringify(body), + }); + +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)", +}; + +describe("postLeadNotificationHandler", () => { + const original = process.env.INTERNAL_API_SECRET; + + beforeEach(() => { + process.env.INTERNAL_API_SECRET = "s3cr3t"; + vi.mocked(sendSalesNotification).mockClear(); + }); + afterEach(() => { + if (original === undefined) delete process.env.INTERNAL_API_SECRET; + else process.env.INTERNAL_API_SECRET = original; + }); + + it("notifies on a valid lead and reports that it did", async () => { + const response = await postLeadNotificationHandler(post(lead)); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + status: "success", + notified: true, + }); + + const [{ text, email }] = vi.mocked(sendSalesNotification).mock.calls[0]; + expect(email).toBe("ada@example.com"); + expect(text).toContain("Package: Retained Advisor ($5,000/mo)"); + expect(text).toContain("Company: Test Co"); + expect(text).toContain("Role: Label Owner / GM"); + }); + + it("reports notified:false for a test address so verification is assertable over HTTP", async () => { + const response = await postLeadNotificationHandler( + post({ ...lead, email: "sweetmantech@gmail.com" }), + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ notified: false }); + }); + + it("401s without the internal bearer token", async () => { + const response = await postLeadNotificationHandler(post(lead, "Bearer wrong")); + expect(response.status).toBe(401); + expect(sendSalesNotification).not.toHaveBeenCalled(); + }); + + it("400s on an invalid body", async () => { + const response = await postLeadNotificationHandler(post({ email: "nope" })); + expect(response.status).toBe(400); + expect(sendSalesNotification).not.toHaveBeenCalled(); + }); + + it("400s on a non-JSON body rather than throwing", async () => { + const request = new NextRequest("https://api.recoupable.dev/api/notifications/lead", { + method: "POST", + headers: { authorization: "Bearer s3cr3t" }, + body: "not json", + }); + expect((await postLeadNotificationHandler(request)).status).toBe(400); + }); + + // A Telegram outage must not make the marketing site think the lead was lost — + // the lead is already in Attio by the time this is called. + it("still returns 200 when the notifier itself fails", async () => { + vi.mocked(sendSalesNotification).mockRejectedValueOnce(new Error("telegram down")); + const response = await postLeadNotificationHandler(post(lead)); + expect(response.status).toBe(200); + }); + + it("never echoes the internal secret in a response body", async () => { + const response = await postLeadNotificationHandler(post(lead, "Bearer wrong")); + expect(JSON.stringify(await response.json())).not.toContain("s3cr3t"); + }); +}); diff --git a/lib/notifications/__tests__/validatePostLeadBody.test.ts b/lib/notifications/__tests__/validatePostLeadBody.test.ts new file mode 100644 index 000000000..f64a46b11 --- /dev/null +++ b/lib/notifications/__tests__/validatePostLeadBody.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validatePostLeadBody } from "@/lib/notifications/validatePostLeadBody"; + +describe("validatePostLeadBody", () => { + it("accepts the minimum a lead needs to be actionable", () => { + const result = validatePostLeadBody({ email: "ada@example.com", source: "/audit" }); + expect(result).toEqual({ email: "ada@example.com", source: "/audit" }); + }); + + it("passes the optional triage fields through", () => { + const result = validatePostLeadBody({ + 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", + message: "hello", + }); + expect(result).toMatchObject({ company: "Test Co", role: "Label Owner / GM" }); + }); + + it("400s on a malformed email", () => { + const result = validatePostLeadBody({ email: "not-an-email", source: "/audit" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("400s when source is missing — an unattributed lead cannot be triaged", () => { + const result = validatePostLeadBody({ email: "ada@example.com" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("400s on a non-object body", () => { + expect(validatePostLeadBody(null)).toBeInstanceOf(NextResponse); + }); +}); diff --git a/lib/notifications/buildLeadNotification.ts b/lib/notifications/buildLeadNotification.ts new file mode 100644 index 000000000..916e2bb2f --- /dev/null +++ b/lib/notifications/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/notifications/postLeadNotificationHandler.ts b/lib/notifications/postLeadNotificationHandler.ts new file mode 100644 index 000000000..cc1e64d3d --- /dev/null +++ b/lib/notifications/postLeadNotificationHandler.ts @@ -0,0 +1,59 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateInternalRequest } from "@/lib/internal/validateInternalRequest"; +import { validatePostLeadBody } from "@/lib/notifications/validatePostLeadBody"; +import { buildLeadNotification } from "@/lib/notifications/buildLeadNotification"; +import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; +import { isTestEmail } from "@/lib/emails/isTestEmail"; + +/** + * Handler for POST /api/notifications/lead. + * + * Pages a human on Telegram when the marketing site captures a lead. The + * notifier itself already existed and ran in production for Stripe events; it + * was simply unreachable over HTTP, so no marketing capture ever announced + * itself (recoupable/chat#1800). + * + * Always 200s once the body is valid. The lead is already stored in Attio by + * the time this is called, so a Telegram outage must not tell the caller the + * capture failed — that would trade a silent loss for a false alarm. + * + * @param request - The incoming request + * @returns 200 with whether a message was sent, or 401/400 on rejection. + */ +export async function postLeadNotificationHandler(request: NextRequest): Promise { + const denied = validateInternalRequest(request); + if (denied) return denied; + + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { status: "error", error: "Invalid JSON body" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const validated = validatePostLeadBody(body); + if (validated instanceof NextResponse) return validated; + + // sendSalesNotification applies this filter itself, and must keep doing so for + // its eight Stripe callers. Reading it here is what lets the response state + // whether a message went out, so the test-address case is assertable over HTTP + // instead of by watching a Telegram channel. + const notified = !isTestEmail(validated.email); + + await sendSalesNotification({ + email: validated.email, + text: buildLeadNotification(validated), + }).catch(error => { + console.error("[notifications/lead] notifier failed:", error); + }); + + return NextResponse.json( + { status: "success", notified }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/notifications/validatePostLeadBody.ts b/lib/notifications/validatePostLeadBody.ts new file mode 100644 index 000000000..aabe3e0b2 --- /dev/null +++ b/lib/notifications/validatePostLeadBody.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const postLeadBodySchema = z.object({ + email: z.string().email("email must be a valid email address"), + source: z.string().min(1, "source is required"), + name: z.string().optional(), + company: z.string().optional(), + role: z.string().optional(), + package: z.string().optional(), + rosterSize: z.string().optional(), + message: z.string().optional(), +}); + +export type PostLeadBody = z.infer; + +/** + * Validates the request body for POST /api/notifications/lead. + * + * `source` is required rather than optional: a notification that cannot say + * which form produced the lead cannot be triaged, which is the whole point of + * the endpoint (recoupable/chat#1800). + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body. + */ +export function validatePostLeadBody(body: unknown): NextResponse | PostLeadBody { + const result = postLeadBodySchema.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; +} From 9c403ba5b41fb5cc332777f041de75e3e54175c0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 12 Aug 2026 17:14:10 -0500 Subject: [PATCH 2/5] refactor: drop INTERNAL_API_SECRET gate from /api/notifications/lead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision on chat#1800 (2026-08-12, Patrick): the capture forms feeding this endpoint are public and unauthenticated, so a bearer secret only blocks direct curls, not spam — the same Telegram message is reachable through any form. isTestEmail filtering stays; add auth (e.g. Privy) only if abuse materializes. Also removes the env-var setup that blocked preview verification. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/notifications/lead/route.ts | 4 +- .../__tests__/validateInternalRequest.test.ts | 47 ------------------- lib/internal/validateInternalRequest.ts | 30 ------------ .../postLeadNotificationHandler.test.ts | 25 ++-------- .../postLeadNotificationHandler.ts | 10 ++-- 5 files changed, 10 insertions(+), 106 deletions(-) delete mode 100644 lib/internal/__tests__/validateInternalRequest.test.ts delete mode 100644 lib/internal/validateInternalRequest.ts diff --git a/app/api/notifications/lead/route.ts b/app/api/notifications/lead/route.ts index ddff744c2..523bd75ee 100644 --- a/app/api/notifications/lead/route.ts +++ b/app/api/notifications/lead/route.ts @@ -19,8 +19,8 @@ export async function OPTIONS() { * POST /api/notifications/lead * * Pages the admin Telegram chat when the marketing site captures a lead. - * Internal, server-to-server only — requires - * `Authorization: Bearer ${INTERNAL_API_SECRET}`. + * Unauthenticated by decision (chat#1800, 2026-08-12) — the public capture + * forms feeding it make endpoint auth moot; revisit if spammed. * * Body parameters: * - email (required): the lead's email address diff --git a/lib/internal/__tests__/validateInternalRequest.test.ts b/lib/internal/__tests__/validateInternalRequest.test.ts deleted file mode 100644 index 16ac4f99b..000000000 --- a/lib/internal/__tests__/validateInternalRequest.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { NextRequest } from "next/server"; -import { validateInternalRequest } from "@/lib/internal/validateInternalRequest"; - -const requestWith = (authorization?: string) => - new NextRequest("https://api.recoupable.dev/api/notifications/lead", { - method: "POST", - headers: authorization ? { authorization } : {}, - }); - -describe("validateInternalRequest", () => { - const original = process.env.INTERNAL_API_SECRET; - - beforeEach(() => { - process.env.INTERNAL_API_SECRET = "s3cr3t"; - }); - afterEach(() => { - if (original === undefined) delete process.env.INTERNAL_API_SECRET; - else process.env.INTERNAL_API_SECRET = original; - }); - - it("returns null when the bearer token matches", () => { - expect(validateInternalRequest(requestWith("Bearer s3cr3t"))).toBeNull(); - }); - - it("401s when the token does not match", async () => { - const denied = validateInternalRequest(requestWith("Bearer wrong")); - expect(denied?.status).toBe(401); - }); - - it("401s when the header is absent", () => { - expect(validateInternalRequest(requestWith())?.status).toBe(401); - }); - - // A missing secret must not become an open door — an unconfigured deployment - // should fail closed and loudly, exactly as validateCronRequest does. - it("500s when INTERNAL_API_SECRET is unset (misconfiguration, not open door)", () => { - delete process.env.INTERNAL_API_SECRET; - expect(validateInternalRequest(requestWith("Bearer anything"))?.status).toBe(500); - }); - - it("never echoes the configured secret in the response body", async () => { - const denied = validateInternalRequest(requestWith("Bearer wrong")); - const body = await denied!.json(); - expect(JSON.stringify(body)).not.toContain("s3cr3t"); - }); -}); diff --git a/lib/internal/validateInternalRequest.ts b/lib/internal/validateInternalRequest.ts deleted file mode 100644 index b200e0456..000000000 --- a/lib/internal/validateInternalRequest.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; - -/** - * Gates an internal route to trusted server-to-server callers — currently the - * marketing site posting lead notifications. Callers send - * `Authorization: Bearer ${INTERNAL_API_SECRET}`; we require an exact match. - * - * Mirrors `validateCronRequest`, including its stance that a missing secret is - * a misconfiguration (500) rather than an open door. Kept separate from it - * because these callers are not Vercel Cron and must not share CRON_SECRET. - * - * @param request - The incoming Next.js request. - * @returns A NextResponse to short-circuit on failure, or null when authorized. - */ -export function validateInternalRequest(request: NextRequest): NextResponse | null { - const secret = process.env.INTERNAL_API_SECRET; - if (!secret) { - console.error("[internal] INTERNAL_API_SECRET is not configured"); - return NextResponse.json( - { status: "error", message: "Internal server error" }, - { status: 500 }, - ); - } - - if (request.headers.get("authorization") !== `Bearer ${secret}`) { - return NextResponse.json({ status: "error", message: "Unauthorized" }, { status: 401 }); - } - - return null; -} diff --git a/lib/notifications/__tests__/postLeadNotificationHandler.test.ts b/lib/notifications/__tests__/postLeadNotificationHandler.test.ts index 43a8023c5..c4f63ac5a 100644 --- a/lib/notifications/__tests__/postLeadNotificationHandler.test.ts +++ b/lib/notifications/__tests__/postLeadNotificationHandler.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; import { postLeadNotificationHandler } from "@/lib/notifications/postLeadNotificationHandler"; import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; @@ -7,10 +7,10 @@ vi.mock("@/lib/telegram/sendSalesNotification", () => ({ sendSalesNotification: vi.fn().mockResolvedValue(undefined), })); -const post = (body: unknown, authorization = "Bearer s3cr3t") => +const post = (body: unknown) => new NextRequest("https://api.recoupable.dev/api/notifications/lead", { method: "POST", - headers: { authorization, "content-type": "application/json" }, + headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); @@ -24,16 +24,9 @@ const lead = { }; describe("postLeadNotificationHandler", () => { - const original = process.env.INTERNAL_API_SECRET; - beforeEach(() => { - process.env.INTERNAL_API_SECRET = "s3cr3t"; vi.mocked(sendSalesNotification).mockClear(); }); - afterEach(() => { - if (original === undefined) delete process.env.INTERNAL_API_SECRET; - else process.env.INTERNAL_API_SECRET = original; - }); it("notifies on a valid lead and reports that it did", async () => { const response = await postLeadNotificationHandler(post(lead)); @@ -58,12 +51,6 @@ describe("postLeadNotificationHandler", () => { await expect(response.json()).resolves.toMatchObject({ notified: false }); }); - it("401s without the internal bearer token", async () => { - const response = await postLeadNotificationHandler(post(lead, "Bearer wrong")); - expect(response.status).toBe(401); - expect(sendSalesNotification).not.toHaveBeenCalled(); - }); - it("400s on an invalid body", async () => { const response = await postLeadNotificationHandler(post({ email: "nope" })); expect(response.status).toBe(400); @@ -73,7 +60,6 @@ describe("postLeadNotificationHandler", () => { it("400s on a non-JSON body rather than throwing", async () => { const request = new NextRequest("https://api.recoupable.dev/api/notifications/lead", { method: "POST", - headers: { authorization: "Bearer s3cr3t" }, body: "not json", }); expect((await postLeadNotificationHandler(request)).status).toBe(400); @@ -86,9 +72,4 @@ describe("postLeadNotificationHandler", () => { const response = await postLeadNotificationHandler(post(lead)); expect(response.status).toBe(200); }); - - it("never echoes the internal secret in a response body", async () => { - const response = await postLeadNotificationHandler(post(lead, "Bearer wrong")); - expect(JSON.stringify(await response.json())).not.toContain("s3cr3t"); - }); }); diff --git a/lib/notifications/postLeadNotificationHandler.ts b/lib/notifications/postLeadNotificationHandler.ts index cc1e64d3d..c65968981 100644 --- a/lib/notifications/postLeadNotificationHandler.ts +++ b/lib/notifications/postLeadNotificationHandler.ts @@ -1,7 +1,6 @@ import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateInternalRequest } from "@/lib/internal/validateInternalRequest"; import { validatePostLeadBody } from "@/lib/notifications/validatePostLeadBody"; import { buildLeadNotification } from "@/lib/notifications/buildLeadNotification"; import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; @@ -15,17 +14,18 @@ import { isTestEmail } from "@/lib/emails/isTestEmail"; * was simply unreachable over HTTP, so no marketing capture ever announced * itself (recoupable/chat#1800). * + * Unauthenticated by decision (chat#1800, 2026-08-12): the capture forms that + * feed it are public anyway, so a bearer secret only stops direct curls, not + * spam. If abuse materializes, add auth then. + * * Always 200s once the body is valid. The lead is already stored in Attio by * the time this is called, so a Telegram outage must not tell the caller the * capture failed — that would trade a silent loss for a false alarm. * * @param request - The incoming request - * @returns 200 with whether a message was sent, or 401/400 on rejection. + * @returns 200 with whether a message was sent, or 400 on rejection. */ export async function postLeadNotificationHandler(request: NextRequest): Promise { - const denied = validateInternalRequest(request); - if (denied) return denied; - let body: unknown; try { body = await request.json(); From 5bdfc9ece00b661eacd74308e353d219c9fdd4a0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 13 Aug 2026 09:03:05 -0500 Subject: [PATCH 3/5] =?UTF-8?q?feat(leads):=20POST=20/api/leads=20?= =?UTF-8?q?=E2=80=94=20capture=20in=20api:=20Attio=20person=20+=20triage?= =?UTF-8?q?=20note=20+=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks this PR from a notify-only wrapper into the full capture endpoint, per the 2026-08-13 decision on chat#1800: capture ownership moves from marketing into api (the captureValuationLead pattern), so notification is an in-process call and no notify route is exposed. - validatePostLeadsBody: discriminated union (booking | subscribe) carrying the audit/ROI qualifying fields marketing's schema used to strip - buildAttioName: ported from marketing#68 (first/last/full, never undefined) - captureLead: assertPersonByEmail -> buildLeadNote (Advisory Inquiry / audit / ROI) -> sendSalesNotification with Attio deep link; storage is the success criterion and fails loudly; notified mirrors isTestEmail - postLeadsHandler + route: 200 {status,notified,record_url}, 400 bad body, 502 when the lead was not stored - removes app/api/notifications/lead and lib/notifications (superseded) Co-Authored-By: Claude Opus 5 (1M context) --- app/api/leads/route.ts | 44 +++++++++ app/api/notifications/lead/route.ts | 38 -------- lib/leads/__tests__/buildAttioName.test.ts | 30 ++++++ lib/leads/__tests__/buildLeadNote.test.ts | 70 ++++++++++++++ .../__tests__/buildLeadNotification.test.ts | 2 +- lib/leads/__tests__/captureLead.test.ts | 96 +++++++++++++++++++ lib/leads/__tests__/postLeadsHandler.test.ts | 74 ++++++++++++++ .../__tests__/validatePostLeadsBody.test.ts | 92 ++++++++++++++++++ lib/leads/buildAttioName.ts | 30 ++++++ lib/leads/buildLeadNote.ts | 69 +++++++++++++ .../buildLeadNotification.ts | 0 lib/leads/captureLead.ts | 71 ++++++++++++++ lib/leads/packageLabel.ts | 17 ++++ lib/leads/postLeadsHandler.ts | 46 +++++++++ lib/leads/validatePostLeadsBody.ts | 71 ++++++++++++++ .../postLeadNotificationHandler.test.ts | 75 --------------- .../__tests__/validatePostLeadBody.test.ts | 40 -------- .../postLeadNotificationHandler.ts | 59 ------------ lib/notifications/validatePostLeadBody.ts | 44 --------- 19 files changed, 711 insertions(+), 257 deletions(-) create mode 100644 app/api/leads/route.ts delete mode 100644 app/api/notifications/lead/route.ts create mode 100644 lib/leads/__tests__/buildAttioName.test.ts create mode 100644 lib/leads/__tests__/buildLeadNote.test.ts rename lib/{notifications => leads}/__tests__/buildLeadNotification.test.ts (95%) create mode 100644 lib/leads/__tests__/captureLead.test.ts create mode 100644 lib/leads/__tests__/postLeadsHandler.test.ts create mode 100644 lib/leads/__tests__/validatePostLeadsBody.test.ts create mode 100644 lib/leads/buildAttioName.ts create mode 100644 lib/leads/buildLeadNote.ts rename lib/{notifications => leads}/buildLeadNotification.ts (100%) create mode 100644 lib/leads/captureLead.ts create mode 100644 lib/leads/packageLabel.ts create mode 100644 lib/leads/postLeadsHandler.ts create mode 100644 lib/leads/validatePostLeadsBody.ts delete mode 100644 lib/notifications/__tests__/postLeadNotificationHandler.test.ts delete mode 100644 lib/notifications/__tests__/validatePostLeadBody.test.ts delete mode 100644 lib/notifications/postLeadNotificationHandler.ts delete mode 100644 lib/notifications/validatePostLeadBody.ts diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts new file mode 100644 index 000000000..8e6f90135 --- /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/app/api/notifications/lead/route.ts b/app/api/notifications/lead/route.ts deleted file mode 100644 index 523bd75ee..000000000 --- a/app/api/notifications/lead/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { postLeadNotificationHandler } from "@/lib/notifications/postLeadNotificationHandler"; - -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/notifications/lead - * - * Pages the admin Telegram chat when the marketing site captures a lead. - * Unauthenticated by decision (chat#1800, 2026-08-12) — the public capture - * forms feeding it make endpoint auth moot; revisit if spammed. - * - * Body parameters: - * - email (required): the lead's email address - * - source (required): the capturing surface, e.g. `/advisory/book` - * - name, company, role, package, rosterSize, message (optional): triage fields - * - * Returns `{ status, notified }` — `notified` is false for internal test - * addresses, which are filtered out of the channel by design. - * - * @param request - The request object. - * @returns A NextResponse describing whether a message was sent. - */ -export async function POST(request: NextRequest): Promise { - return postLeadNotificationHandler(request); -} diff --git a/lib/leads/__tests__/buildAttioName.test.ts b/lib/leads/__tests__/buildAttioName.test.ts new file mode 100644 index 000000000..a5667f09f --- /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 000000000..afadb30be --- /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/notifications/__tests__/buildLeadNotification.test.ts b/lib/leads/__tests__/buildLeadNotification.test.ts similarity index 95% rename from lib/notifications/__tests__/buildLeadNotification.test.ts rename to lib/leads/__tests__/buildLeadNotification.test.ts index 146aec770..e9d3d4973 100644 --- a/lib/notifications/__tests__/buildLeadNotification.test.ts +++ b/lib/leads/__tests__/buildLeadNotification.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildLeadNotification } from "@/lib/notifications/buildLeadNotification"; +import { buildLeadNotification } from "@/lib/leads/buildLeadNotification"; const lead = { email: "ada@example.com", diff --git a/lib/leads/__tests__/captureLead.test.ts b/lib/leads/__tests__/captureLead.test.ts new file mode 100644 index 000000000..7ac00acb5 --- /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 000000000..cd6b4da86 --- /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 000000000..4778b114c --- /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 000000000..e1e98e905 --- /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 000000000..a9a83dc8b --- /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/notifications/buildLeadNotification.ts b/lib/leads/buildLeadNotification.ts similarity index 100% rename from lib/notifications/buildLeadNotification.ts rename to lib/leads/buildLeadNotification.ts diff --git a/lib/leads/captureLead.ts b/lib/leads/captureLead.ts new file mode 100644 index 000000000..d45f5083c --- /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 000000000..82d1b7d29 --- /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 000000000..60a66d255 --- /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 000000000..bdb788b8c --- /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; +} diff --git a/lib/notifications/__tests__/postLeadNotificationHandler.test.ts b/lib/notifications/__tests__/postLeadNotificationHandler.test.ts deleted file mode 100644 index c4f63ac5a..000000000 --- a/lib/notifications/__tests__/postLeadNotificationHandler.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; -import { postLeadNotificationHandler } from "@/lib/notifications/postLeadNotificationHandler"; -import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; - -vi.mock("@/lib/telegram/sendSalesNotification", () => ({ - sendSalesNotification: vi.fn().mockResolvedValue(undefined), -})); - -const post = (body: unknown) => - new NextRequest("https://api.recoupable.dev/api/notifications/lead", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); - -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)", -}; - -describe("postLeadNotificationHandler", () => { - beforeEach(() => { - vi.mocked(sendSalesNotification).mockClear(); - }); - - it("notifies on a valid lead and reports that it did", async () => { - const response = await postLeadNotificationHandler(post(lead)); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ - status: "success", - notified: true, - }); - - const [{ text, email }] = vi.mocked(sendSalesNotification).mock.calls[0]; - expect(email).toBe("ada@example.com"); - expect(text).toContain("Package: Retained Advisor ($5,000/mo)"); - expect(text).toContain("Company: Test Co"); - expect(text).toContain("Role: Label Owner / GM"); - }); - - it("reports notified:false for a test address so verification is assertable over HTTP", async () => { - const response = await postLeadNotificationHandler( - post({ ...lead, email: "sweetmantech@gmail.com" }), - ); - expect(response.status).toBe(200); - await expect(response.json()).resolves.toMatchObject({ notified: false }); - }); - - it("400s on an invalid body", async () => { - const response = await postLeadNotificationHandler(post({ email: "nope" })); - expect(response.status).toBe(400); - expect(sendSalesNotification).not.toHaveBeenCalled(); - }); - - it("400s on a non-JSON body rather than throwing", async () => { - const request = new NextRequest("https://api.recoupable.dev/api/notifications/lead", { - method: "POST", - body: "not json", - }); - expect((await postLeadNotificationHandler(request)).status).toBe(400); - }); - - // A Telegram outage must not make the marketing site think the lead was lost — - // the lead is already in Attio by the time this is called. - it("still returns 200 when the notifier itself fails", async () => { - vi.mocked(sendSalesNotification).mockRejectedValueOnce(new Error("telegram down")); - const response = await postLeadNotificationHandler(post(lead)); - expect(response.status).toBe(200); - }); -}); diff --git a/lib/notifications/__tests__/validatePostLeadBody.test.ts b/lib/notifications/__tests__/validatePostLeadBody.test.ts deleted file mode 100644 index f64a46b11..000000000 --- a/lib/notifications/__tests__/validatePostLeadBody.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { NextResponse } from "next/server"; -import { validatePostLeadBody } from "@/lib/notifications/validatePostLeadBody"; - -describe("validatePostLeadBody", () => { - it("accepts the minimum a lead needs to be actionable", () => { - const result = validatePostLeadBody({ email: "ada@example.com", source: "/audit" }); - expect(result).toEqual({ email: "ada@example.com", source: "/audit" }); - }); - - it("passes the optional triage fields through", () => { - const result = validatePostLeadBody({ - 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", - message: "hello", - }); - expect(result).toMatchObject({ company: "Test Co", role: "Label Owner / GM" }); - }); - - it("400s on a malformed email", () => { - const result = validatePostLeadBody({ email: "not-an-email", source: "/audit" }); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - }); - - it("400s when source is missing — an unattributed lead cannot be triaged", () => { - const result = validatePostLeadBody({ email: "ada@example.com" }); - expect(result).toBeInstanceOf(NextResponse); - expect((result as NextResponse).status).toBe(400); - }); - - it("400s on a non-object body", () => { - expect(validatePostLeadBody(null)).toBeInstanceOf(NextResponse); - }); -}); diff --git a/lib/notifications/postLeadNotificationHandler.ts b/lib/notifications/postLeadNotificationHandler.ts deleted file mode 100644 index c65968981..000000000 --- a/lib/notifications/postLeadNotificationHandler.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validatePostLeadBody } from "@/lib/notifications/validatePostLeadBody"; -import { buildLeadNotification } from "@/lib/notifications/buildLeadNotification"; -import { sendSalesNotification } from "@/lib/telegram/sendSalesNotification"; -import { isTestEmail } from "@/lib/emails/isTestEmail"; - -/** - * Handler for POST /api/notifications/lead. - * - * Pages a human on Telegram when the marketing site captures a lead. The - * notifier itself already existed and ran in production for Stripe events; it - * was simply unreachable over HTTP, so no marketing capture ever announced - * itself (recoupable/chat#1800). - * - * Unauthenticated by decision (chat#1800, 2026-08-12): the capture forms that - * feed it are public anyway, so a bearer secret only stops direct curls, not - * spam. If abuse materializes, add auth then. - * - * Always 200s once the body is valid. The lead is already stored in Attio by - * the time this is called, so a Telegram outage must not tell the caller the - * capture failed — that would trade a silent loss for a false alarm. - * - * @param request - The incoming request - * @returns 200 with whether a message was sent, or 400 on rejection. - */ -export async function postLeadNotificationHandler(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 = validatePostLeadBody(body); - if (validated instanceof NextResponse) return validated; - - // sendSalesNotification applies this filter itself, and must keep doing so for - // its eight Stripe callers. Reading it here is what lets the response state - // whether a message went out, so the test-address case is assertable over HTTP - // instead of by watching a Telegram channel. - const notified = !isTestEmail(validated.email); - - await sendSalesNotification({ - email: validated.email, - text: buildLeadNotification(validated), - }).catch(error => { - console.error("[notifications/lead] notifier failed:", error); - }); - - return NextResponse.json( - { status: "success", notified }, - { status: 200, headers: getCorsHeaders() }, - ); -} diff --git a/lib/notifications/validatePostLeadBody.ts b/lib/notifications/validatePostLeadBody.ts deleted file mode 100644 index aabe3e0b2..000000000 --- a/lib/notifications/validatePostLeadBody.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { z } from "zod"; - -export const postLeadBodySchema = z.object({ - email: z.string().email("email must be a valid email address"), - source: z.string().min(1, "source is required"), - name: z.string().optional(), - company: z.string().optional(), - role: z.string().optional(), - package: z.string().optional(), - rosterSize: z.string().optional(), - message: z.string().optional(), -}); - -export type PostLeadBody = z.infer; - -/** - * Validates the request body for POST /api/notifications/lead. - * - * `source` is required rather than optional: a notification that cannot say - * which form produced the lead cannot be triaged, which is the whole point of - * the endpoint (recoupable/chat#1800). - * - * @param body - The request body - * @returns A NextResponse with an error if validation fails, or the validated body. - */ -export function validatePostLeadBody(body: unknown): NextResponse | PostLeadBody { - const result = postLeadBodySchema.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; -} From 5fdd64227cac2eb4833dd2183b17ff922e32e9fd Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 13 Aug 2026 09:29:02 -0500 Subject: [PATCH 4/5] chore: redeploy for negative-test verification (branch-scoped invalid ATTIO_API_KEY) From 0fbf135ed7e37f9c9020b6ad87478373b05c3652 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 13 Aug 2026 09:31:03 -0500 Subject: [PATCH 5/5] chore: redeploy with restored preview env after negative test