-
Notifications
You must be signed in to change notification settings - Fork 10
feat(valuation): lead-capture + Telegram parity across every valuation caller (chat#1885) #785
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { addRecordToList } from "@/lib/attio/addRecordToList"; | ||
|
|
||
| describe("addRecordToList", () => { | ||
| beforeEach(() => vi.stubEnv("ATTIO_API_KEY", "test-key")); | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| it("POSTs a list entry with the given entry values", async () => { | ||
| const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
|
|
||
| await addRecordToList("valuation_leads", "people", "rec_1", { stage: ["New"] }); | ||
|
|
||
| const [url, init] = fetchMock.mock.calls[0]; | ||
| expect(String(url)).toBe("https://api.attio.com/v2/lists/valuation_leads/entries"); | ||
| expect(init.method).toBe("POST"); | ||
| const data = JSON.parse(init.body).data; | ||
| expect(data.parent_object).toBe("people"); | ||
| expect(data.parent_record_id).toBe("rec_1"); | ||
| expect(data.entry_values).toEqual({ stage: ["New"] }); | ||
| }); | ||
|
|
||
| it("logs but does not throw on failure", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad", { status: 422 }))); | ||
| const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| await expect( | ||
| addRecordToList("valuation_leads", "people", "rec_1", { stage: ["New"] }), | ||
| ).resolves.toBeUndefined(); | ||
| expect(errSpy).toHaveBeenCalled(); | ||
| errSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { assertPersonByEmail } from "@/lib/attio/assertPersonByEmail"; | ||
|
|
||
| describe("assertPersonByEmail", () => { | ||
| beforeEach(() => vi.stubEnv("ATTIO_API_KEY", "test-key")); | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| it("PUT-asserts by email and returns the record id", async () => { | ||
| const fetchMock = vi | ||
| .fn() | ||
| .mockResolvedValue( | ||
| new Response(JSON.stringify({ data: { id: { record_id: "rec_1" } } }), { status: 200 }), | ||
| ); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
|
|
||
| const res = await assertPersonByEmail({ email_addresses: [{ email_address: "a@b.com" }] }); | ||
| expect(res.recordId).toBe("rec_1"); | ||
|
|
||
| const [url, init] = fetchMock.mock.calls[0]; | ||
| expect(String(url)).toBe( | ||
| "https://api.attio.com/v2/objects/people/records?matching_attribute=email_addresses", | ||
| ); | ||
| expect(init.method).toBe("PUT"); | ||
| expect(init.headers.Authorization).toBe("Bearer test-key"); | ||
| expect(JSON.parse(init.body).data.values.email_addresses).toEqual([ | ||
| { email_address: "a@b.com" }, | ||
| ]); | ||
| }); | ||
|
|
||
| it("returns an error string on a non-ok response", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("nope", { status: 400 }))); | ||
| const res = await assertPersonByEmail({}); | ||
| expect(res.recordId).toBeUndefined(); | ||
| expect(res.error).toContain("400"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { createNote } from "@/lib/attio/createNote"; | ||
|
|
||
| const note = { | ||
| parentObject: "people", | ||
| parentRecordId: "rec_1", | ||
| title: "Catalog valuation — Mac Miller", | ||
| content: "Valued **$54.6M**", | ||
| }; | ||
|
|
||
| describe("createNote", () => { | ||
| beforeEach(() => vi.stubEnv("ATTIO_API_KEY", "test-key")); | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| it("POSTs a markdown note to the record", async () => { | ||
| const fetchMock = vi.fn().mockResolvedValue(new Response("{}", { status: 200 })); | ||
| vi.stubGlobal("fetch", fetchMock); | ||
|
|
||
| await createNote(note); | ||
|
|
||
| const [url, init] = fetchMock.mock.calls[0]; | ||
| expect(String(url)).toBe("https://api.attio.com/v2/notes"); | ||
| expect(init.method).toBe("POST"); | ||
| const data = JSON.parse(init.body).data; | ||
| expect(data.parent_object).toBe("people"); | ||
| expect(data.parent_record_id).toBe("rec_1"); | ||
| expect(data.format).toBe("markdown"); | ||
| expect(data.title).toContain("Mac Miller"); | ||
| expect(data.content).toContain("$54.6M"); | ||
| }); | ||
|
|
||
| it("logs but does not throw on failure", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bad", { status: 500 }))); | ||
| const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
|
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: Spy on Prompt for AI agents |
||
| await expect(createNote(note)).resolves.toBeUndefined(); | ||
| expect(errSpy).toHaveBeenCalled(); | ||
| errSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; | ||
| import { isRecordInList } from "@/lib/attio/isRecordInList"; | ||
|
|
||
| const LIST_ID = "f5abf9c0-b0a0-4d47-a6b1-37072e415e65"; | ||
|
|
||
| describe("isRecordInList", () => { | ||
| beforeEach(() => vi.stubEnv("ATTIO_API_KEY", "test-key")); | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals(); | ||
| vi.unstubAllEnvs(); | ||
| }); | ||
|
|
||
| it("returns true when the record has an entry in the list", async () => { | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ list_id: LIST_ID }] }))), | ||
| ); | ||
| expect(await isRecordInList("people", "rec_1", LIST_ID)).toBe(true); | ||
| }); | ||
|
|
||
| it("returns false when the record has no entry in the list", async () => { | ||
| vi.stubGlobal( | ||
| "fetch", | ||
| vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [{ list_id: "other" }] }))), | ||
| ); | ||
| expect(await isRecordInList("people", "rec_1", LIST_ID)).toBe(false); | ||
| }); | ||
|
|
||
| it("returns false on a read failure", async () => { | ||
| vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("err", { status: 500 }))); | ||
| expect(await isRecordInList("people", "rec_1", LIST_ID)).toBe(false); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { attioFetch } from "@/lib/attio/request"; | ||
|
|
||
| /** | ||
| * Add a record to a list as a new entry. Best-effort — logs on failure and | ||
| * never throws. Caller is responsible for dedup (see isRecordInList). | ||
| */ | ||
| export async function addRecordToList( | ||
| listSlug: string, | ||
| object: string, | ||
| recordId: string, | ||
| entryValues: Record<string, unknown>, | ||
| ): Promise<void> { | ||
| const res = await attioFetch(`/lists/${listSlug}/entries`, { | ||
| method: "POST", | ||
| body: JSON.stringify({ | ||
| data: { parent_object: object, parent_record_id: recordId, entry_values: entryValues }, | ||
| }), | ||
| }); | ||
| if (!res.ok) { | ||
| console.error(`[attio] add to list ${listSlug} failed: ${res.status} — ${await res.text()}`); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { attioFetch } from "@/lib/attio/request"; | ||
|
|
||
| /** | ||
| * Assert (create-or-update) a Person by email and set attribute values, via | ||
| * Attio's `matching_attribute` upsert. Returns the record id, or an error | ||
| * string if the upsert failed. | ||
| */ | ||
| export async function assertPersonByEmail( | ||
| values: Record<string, unknown>, | ||
| ): Promise<{ recordId?: string; error?: string }> { | ||
| const res = await attioFetch("/objects/people/records?matching_attribute=email_addresses", { | ||
| method: "PUT", | ||
| body: JSON.stringify({ data: { values } }), | ||
| }); | ||
| if (!res.ok) { | ||
| return { error: `Attio person assert failed: ${res.status} — ${await res.text()}` }; | ||
| } | ||
| const data = await res.json().catch(() => null); | ||
| return { recordId: data?.data?.id?.record_id }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { attioFetch } from "@/lib/attio/request"; | ||
|
|
||
| /** | ||
| * Attach a markdown note to a record. Best-effort — logs on failure and never | ||
| * throws, so a notes outage can't break the caller's flow. | ||
| */ | ||
| export async function createNote(note: { | ||
| parentObject: string; | ||
| parentRecordId: string; | ||
| title: string; | ||
| content: string; | ||
| }): Promise<void> { | ||
| const res = await attioFetch("/notes", { | ||
| method: "POST", | ||
| body: JSON.stringify({ | ||
| data: { | ||
| parent_object: note.parentObject, | ||
| parent_record_id: note.parentRecordId, | ||
| title: note.title, | ||
| format: "markdown", | ||
| content: note.content, | ||
| }, | ||
| }), | ||
| }); | ||
| if (!res.ok) { | ||
| console.error(`[attio] note create failed: ${res.status} — ${await res.text()}`); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { attioFetch } from "@/lib/attio/request"; | ||
|
|
||
| /** | ||
| * Whether a record already has an entry in the given list (matched by list id). | ||
| * Used to avoid duplicate pipeline cards. Returns false on any read failure so | ||
| * the caller can decide whether to create — never throws. | ||
| */ | ||
| export async function isRecordInList( | ||
| object: string, | ||
| recordId: string, | ||
| listId: string, | ||
| ): Promise<boolean> { | ||
| const res = await attioFetch(`/objects/${object}/records/${recordId}/entries`, { | ||
|
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. P2: A transient fetch rejection currently escapes despite this helper's best-effort contract, so lead capture can reject instead of treating the membership lookup as absent. Catch request/response parsing failures and validate Prompt for AI agents |
||
| method: "GET", | ||
| }); | ||
| if (!res.ok) return false; | ||
|
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: An Attio membership-read failure is treated as “not in the list,” so Prompt for AI agents |
||
| const data = await res.json().catch(() => null); | ||
| const entries: Array<{ list_id?: string; id?: { list_id?: string } }> = data?.data ?? []; | ||
| return entries.some(e => (e.list_id ?? e.id?.list_id) === listId); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| const ATTIO_BASE_URL = "https://api.attio.com/v2"; | ||
|
|
||
| /** | ||
| * Low-level Attio REST call — prepends the base URL and bearer auth so each | ||
| * operation module (assertPersonByEmail, createNote, …) stays a thin wrapper. | ||
| * Reads ATTIO_API_KEY at call time (server-only); returns the raw Response. | ||
| */ | ||
| export function attioFetch(path: string, init?: RequestInit): Promise<Response> { | ||
|
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: This Attio request helper is exported as Prompt for AI agents |
||
| return fetch(`${ATTIO_BASE_URL}${path}`, { | ||
|
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: A network rejection from Attio escapes the low-level helper and aborts the lead flow before its Telegram alert, despite the wrappers and Prompt for AI agents |
||
| ...init, | ||
| headers: { | ||
| Authorization: `Bearer ${process.env.ATTIO_API_KEY}`, | ||
| "Content-Type": "application/json", | ||
| ...init?.headers, | ||
| }, | ||
| }); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { describe, it, expect } from "vitest"; | ||
| import { usd } from "@/lib/format/usd"; | ||
|
|
||
| describe("usd", () => { | ||
| it("formats whole dollars with thousands separators", () => { | ||
| expect(usd(54_600_000)).toBe("$54,600,000"); | ||
| expect(usd(0)).toBe("$0"); | ||
| expect(usd(1_234)).toBe("$1,234"); | ||
| }); | ||
|
|
||
| it("rounds to the nearest whole dollar", () => { | ||
| expect(usd(33_512_367.0588)).toBe("$33,512,367"); | ||
| expect(usd(0.6)).toBe("$1"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /** | ||
| * Format a number as a whole-dollar USD string, e.g. 54_600_000 → "$54,600,000". | ||
| * Used in the valuation lead's Telegram ping + Attio note (chat#1885). Distinct | ||
| * from `lib/emails/valuationReport/formatCompactUsd` (which compacts to "$54.6M" | ||
| * for the email) and `lib/stripe/formatUsd` (which takes cents, for billing). | ||
| */ | ||
| export function usd(n: number): string { | ||
|
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: Prompt for AI agents |
||
| return `$${Math.round(n).toLocaleString("en-US")}`; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { captureValuationLead } from "@/lib/valuation/captureValuationLead"; | ||
| import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; | ||
| import { upsertValuationLead } from "@/lib/valuation/upsertValuationLead"; | ||
| import { sendMessage } from "@/lib/telegram/sendMessage"; | ||
|
|
||
| vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn() })); | ||
| vi.mock("@/lib/valuation/upsertValuationLead", () => ({ upsertValuationLead: vi.fn() })); | ||
| vi.mock("@/lib/telegram/sendMessage", () => ({ sendMessage: vi.fn() })); | ||
|
|
||
| const input = { | ||
| accountId: "acc_1", | ||
| artistName: "Mac Miller", | ||
| artistId: "4LLpKhyESsyAXpc4laK94U", | ||
| // api valuation band shape ({ low, mid, high }); the lead's "central" is `mid`. | ||
| valueBand: { low: 37_500_000, mid: 54_600_000, high: 76_800_000 }, | ||
| lifetimeStreams: 22_000_000_000, | ||
| followerCount: 13_000_000, | ||
| }; | ||
|
|
||
| describe("captureValuationLead", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.mocked(selectAccountEmails).mockResolvedValue([{ email: "artist@example.com" }] as Awaited< | ||
| ReturnType<typeof selectAccountEmails> | ||
| >); | ||
| vi.mocked(upsertValuationLead).mockResolvedValue({ | ||
| success: true, | ||
| recordUrl: "https://app.attio.com/recoup/person/rec_1/overview", | ||
| }); | ||
| }); | ||
|
|
||
| it("resolves the owner's email from the account and upserts the lead (mid → central)", async () => { | ||
| await captureValuationLead(input); | ||
|
|
||
| expect(selectAccountEmails).toHaveBeenCalledWith({ accountIds: "acc_1" }); | ||
| expect(upsertValuationLead).toHaveBeenCalledWith({ | ||
| email: "artist@example.com", | ||
| artistName: "Mac Miller", | ||
| artistId: "4LLpKhyESsyAXpc4laK94U", | ||
| valueBand: { low: 37_500_000, central: 54_600_000, high: 76_800_000 }, | ||
| lifetimeStreams: 22_000_000_000, | ||
| followerCount: 13_000_000, | ||
| }); | ||
| }); | ||
|
|
||
| it("pings Telegram with the lead + value band + Attio deep link", async () => { | ||
| await captureValuationLead(input); | ||
|
|
||
| expect(sendMessage).toHaveBeenCalledTimes(1); | ||
| const msg = vi.mocked(sendMessage).mock.calls[0][0] as string; | ||
| expect(msg).toContain("💰 Valuation lead"); | ||
| expect(msg).toContain("artist@example.com"); | ||
| expect(msg).toContain("Mac Miller"); | ||
| expect(msg).toContain("$54,600,000"); | ||
| expect(msg).toContain("$37,500,000–$76,800,000"); | ||
| expect(msg).toContain("https://app.attio.com/recoup/person/rec_1/overview"); | ||
| }); | ||
|
|
||
| it("skips entirely (no Attio, no Telegram) when the account has no email", async () => { | ||
| vi.mocked(selectAccountEmails).mockResolvedValue( | ||
| [] as Awaited<ReturnType<typeof selectAccountEmails>>, | ||
| ); | ||
| await captureValuationLead(input); | ||
| expect(upsertValuationLead).not.toHaveBeenCalled(); | ||
| expect(sendMessage).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("still pings Telegram (without a link) and logs when the Attio upsert fails", async () => { | ||
| vi.mocked(upsertValuationLead).mockResolvedValue({ success: false, error: "boom" }); | ||
| const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| await captureValuationLead(input); | ||
| expect(sendMessage).toHaveBeenCalledTimes(1); | ||
| const msg = vi.mocked(sendMessage).mock.calls[0][0] as string; | ||
| expect(msg).not.toContain("Attio:"); | ||
| expect(errSpy).toHaveBeenCalled(); | ||
| errSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it("never throws when Telegram fails (best-effort)", async () => { | ||
| vi.mocked(sendMessage).mockRejectedValue(new Error("telegram down")); | ||
| const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); | ||
| await expect(captureValuationLead(input)).resolves.toBeUndefined(); | ||
| errSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it("omits optional signals from the lead when they are not known", async () => { | ||
| const { lifetimeStreams: _s, followerCount: _f, ...minimal } = input; | ||
| await captureValuationLead(minimal); | ||
| const lead = vi.mocked(upsertValuationLead).mock.calls[0][0]; | ||
| expect(lead).not.toHaveProperty("lifetimeStreams"); | ||
| expect(lead).not.toHaveProperty("followerCount"); | ||
| }); | ||
| }); |
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.
P3: A failed assertion in this test can leak the
console.errorspy into later tests becausemockRestore()is only reached on the success path. Moving spy cleanup into anafterEach/finallypath would preserve test isolation.Prompt for AI agents