diff --git a/lib/attio/__tests__/addRecordToList.test.ts b/lib/attio/__tests__/addRecordToList.test.ts new file mode 100644 index 00000000..74506eda --- /dev/null +++ b/lib/attio/__tests__/addRecordToList.test.ts @@ -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(); + }); +}); diff --git a/lib/attio/__tests__/assertPersonByEmail.test.ts b/lib/attio/__tests__/assertPersonByEmail.test.ts new file mode 100644 index 00000000..6584f1f4 --- /dev/null +++ b/lib/attio/__tests__/assertPersonByEmail.test.ts @@ -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"); + }); +}); diff --git a/lib/attio/__tests__/createNote.test.ts b/lib/attio/__tests__/createNote.test.ts new file mode 100644 index 00000000..5d2ca9f5 --- /dev/null +++ b/lib/attio/__tests__/createNote.test.ts @@ -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(() => {}); + await expect(createNote(note)).resolves.toBeUndefined(); + expect(errSpy).toHaveBeenCalled(); + errSpy.mockRestore(); + }); +}); diff --git a/lib/attio/__tests__/isRecordInList.test.ts b/lib/attio/__tests__/isRecordInList.test.ts new file mode 100644 index 00000000..51705188 --- /dev/null +++ b/lib/attio/__tests__/isRecordInList.test.ts @@ -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); + }); +}); diff --git a/lib/attio/addRecordToList.ts b/lib/attio/addRecordToList.ts new file mode 100644 index 00000000..2290c790 --- /dev/null +++ b/lib/attio/addRecordToList.ts @@ -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, +): Promise { + 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()}`); + } +} diff --git a/lib/attio/assertPersonByEmail.ts b/lib/attio/assertPersonByEmail.ts new file mode 100644 index 00000000..fda77e01 --- /dev/null +++ b/lib/attio/assertPersonByEmail.ts @@ -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, +): 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 }; +} diff --git a/lib/attio/createNote.ts b/lib/attio/createNote.ts new file mode 100644 index 00000000..438a6be1 --- /dev/null +++ b/lib/attio/createNote.ts @@ -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 { + 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()}`); + } +} diff --git a/lib/attio/isRecordInList.ts b/lib/attio/isRecordInList.ts new file mode 100644 index 00000000..5327752f --- /dev/null +++ b/lib/attio/isRecordInList.ts @@ -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 { + const res = await attioFetch(`/objects/${object}/records/${recordId}/entries`, { + method: "GET", + }); + if (!res.ok) return false; + 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); +} diff --git a/lib/attio/request.ts b/lib/attio/request.ts new file mode 100644 index 00000000..cbc19bc3 --- /dev/null +++ b/lib/attio/request.ts @@ -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 { + return fetch(`${ATTIO_BASE_URL}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${process.env.ATTIO_API_KEY}`, + "Content-Type": "application/json", + ...init?.headers, + }, + }); +} diff --git a/lib/format/__tests__/usd.test.ts b/lib/format/__tests__/usd.test.ts new file mode 100644 index 00000000..b3abc041 --- /dev/null +++ b/lib/format/__tests__/usd.test.ts @@ -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"); + }); +}); diff --git a/lib/format/usd.ts b/lib/format/usd.ts new file mode 100644 index 00000000..6eabf55e --- /dev/null +++ b/lib/format/usd.ts @@ -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 { + return `$${Math.round(n).toLocaleString("en-US")}`; +} diff --git a/lib/valuation/__tests__/captureValuationLead.test.ts b/lib/valuation/__tests__/captureValuationLead.test.ts new file mode 100644 index 00000000..a3c8e0f3 --- /dev/null +++ b/lib/valuation/__tests__/captureValuationLead.test.ts @@ -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 + >); + 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>, + ); + 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"); + }); +}); diff --git a/lib/valuation/__tests__/leadAttributes.test.ts b/lib/valuation/__tests__/leadAttributes.test.ts new file mode 100644 index 00000000..e4becc12 --- /dev/null +++ b/lib/valuation/__tests__/leadAttributes.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { leadAttributes } from "@/lib/valuation/leadAttributes"; + +const lead = { + 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, +}; + +describe("leadAttributes", () => { + it("maps the lead to Attio Person attribute values", () => { + const v = leadAttributes(lead, "2026-06-17"); + expect(v.email_addresses).toEqual([{ email_address: "artist@example.com" }]); + expect(v.lead_source).toBe("Catalog Valuation"); + expect(v.est_catalog_value).toBe(54_600_000); // band central = lead score + expect(v.looked_up_artist).toBe("Mac Miller"); + expect(v.spotify_artist_url).toBe("https://open.spotify.com/artist/4LLpKhyESsyAXpc4laK94U"); + expect(v.lifetime_streams).toBe(22_000_000_000); + expect(v.follower_count).toBe(13_000_000); + expect(v.valued_at).toBe("2026-06-17"); + }); + + it("omits follower_count when it is not known", () => { + const { followerCount: _omit, ...noFollowers } = lead; + expect(leadAttributes(noFollowers, "2026-06-17")).not.toHaveProperty("follower_count"); + }); + + it("rounds est_catalog_value to a whole number (Attio currency rejects >4 decimals)", () => { + const fractional = { ...lead, valueBand: { low: 1, central: 33_512_367.0588, high: 2 } }; + expect(leadAttributes(fractional, "2026-06-17").est_catalog_value).toBe(33_512_367); + }); +}); diff --git a/lib/valuation/__tests__/leadNoteContent.test.ts b/lib/valuation/__tests__/leadNoteContent.test.ts new file mode 100644 index 00000000..93e2c384 --- /dev/null +++ b/lib/valuation/__tests__/leadNoteContent.test.ts @@ -0,0 +1,28 @@ +import { describe, it, expect } from "vitest"; +import { leadNoteContent } from "@/lib/valuation/leadNoteContent"; + +const lead = { + 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, +}; + +describe("leadNoteContent", () => { + it("renders a markdown chronology entry with the artist link, value, and signals", () => { + const c = leadNoteContent(lead, "2026-06-17"); + expect(c).toContain("[Mac Miller](https://open.spotify.com/artist/4LLpKhyESsyAXpc4laK94U)"); + expect(c).toContain("**$54,600,000**"); + expect(c).toContain("$37,500,000–$76,800,000"); + expect(c).toContain("Lifetime streams: 22,000,000,000"); + expect(c).toContain("Followers: 13,000,000"); + expect(c).toContain("Run date: 2026-06-17"); + }); + + it("omits the followers line when it is not known", () => { + const { followerCount: _omit, ...noFollowers } = lead; + expect(leadNoteContent(noFollowers, "2026-06-17")).not.toContain("Followers:"); + }); +}); diff --git a/lib/valuation/__tests__/upsertValuationLead.test.ts b/lib/valuation/__tests__/upsertValuationLead.test.ts new file mode 100644 index 00000000..aa1734ab --- /dev/null +++ b/lib/valuation/__tests__/upsertValuationLead.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { upsertValuationLead } from "@/lib/valuation/upsertValuationLead"; +import { assertPersonByEmail } from "@/lib/attio/assertPersonByEmail"; +import { createNote } from "@/lib/attio/createNote"; +import { isRecordInList } from "@/lib/attio/isRecordInList"; +import { addRecordToList } from "@/lib/attio/addRecordToList"; + +vi.mock("@/lib/attio/assertPersonByEmail", () => ({ assertPersonByEmail: vi.fn() })); +vi.mock("@/lib/attio/createNote", () => ({ createNote: vi.fn() })); +vi.mock("@/lib/attio/isRecordInList", () => ({ isRecordInList: vi.fn() })); +vi.mock("@/lib/attio/addRecordToList", () => ({ addRecordToList: vi.fn() })); + +const lead = { + 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, +}; + +describe("upsertValuationLead", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("ATTIO_API_KEY", "test-key"); + vi.mocked(assertPersonByEmail).mockResolvedValue({ recordId: "rec_123" }); + vi.mocked(isRecordInList).mockResolvedValue(false); + }); + afterEach(() => vi.unstubAllEnvs()); + + it("asserts the person with the qualifying attributes and returns the record link", async () => { + const res = await upsertValuationLead(lead); + expect(res).toEqual({ + success: true, + recordUrl: "https://app.attio.com/recoup/person/rec_123/overview", + }); + + const values = vi.mocked(assertPersonByEmail).mock.calls[0][0]; + expect(values.email_addresses).toEqual([{ email_address: "artist@example.com" }]); + expect(values.lead_source).toBe("Catalog Valuation"); + expect(values.est_catalog_value).toBe(54_600_000); // band central = lead score + expect(values.looked_up_artist).toBe("Mac Miller"); + expect(values.spotify_artist_url).toBe( + "https://open.spotify.com/artist/4LLpKhyESsyAXpc4laK94U", + ); + expect(values.lifetime_streams).toBe(22_000_000_000); + expect(values.follower_count).toBe(13_000_000); + expect(values.valued_at).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("attaches a chronology note for the run", async () => { + await upsertValuationLead(lead); + const note = vi.mocked(createNote).mock.calls[0][0]; + expect(note.parentObject).toBe("people"); + expect(note.parentRecordId).toBe("rec_123"); + expect(note.title).toContain("Mac Miller"); + expect(note.content).toContain("Mac Miller"); + expect(note.content).toContain("$54,600,000"); + }); + + it("adds the person to the pipeline at New when not already on it", async () => { + await upsertValuationLead(lead); + expect(isRecordInList).toHaveBeenCalledWith("people", "rec_123", expect.any(String)); + expect(addRecordToList).toHaveBeenCalledWith("valuation_leads", "people", "rec_123", { + stage: ["New"], + }); + }); + + it("still notes a re-run but does NOT add a duplicate card", async () => { + vi.mocked(isRecordInList).mockResolvedValue(true); + await upsertValuationLead(lead); + expect(createNote).toHaveBeenCalledTimes(1); // chronology still grows + expect(addRecordToList).not.toHaveBeenCalled(); // no duplicate card + }); + + it("omits follower_count when it is not known", async () => { + const { followerCount: _omit, ...noFollowers } = lead; + await upsertValuationLead(noFollowers); + const values = vi.mocked(assertPersonByEmail).mock.calls[0][0]; + expect(values).not.toHaveProperty("follower_count"); + }); + + it("returns an error without touching Attio when the key is missing", async () => { + vi.unstubAllEnvs(); + const res = await upsertValuationLead(lead); + expect(res.success).toBe(false); + expect(res.error).toContain("ATTIO_API_KEY"); + expect(assertPersonByEmail).not.toHaveBeenCalled(); + }); + + it("fails fast (no note, no list work) when the person assert errors", async () => { + vi.mocked(assertPersonByEmail).mockResolvedValue({ error: "Attio person assert failed: 400" }); + const res = await upsertValuationLead(lead); + expect(res.success).toBe(false); + expect(res.error).toContain("400"); + expect(createNote).not.toHaveBeenCalled(); + expect(addRecordToList).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/valuation/captureValuationLead.ts b/lib/valuation/captureValuationLead.ts new file mode 100644 index 00000000..f6058895 --- /dev/null +++ b/lib/valuation/captureValuationLead.ts @@ -0,0 +1,73 @@ +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { upsertValuationLead } from "@/lib/valuation/upsertValuationLead"; +import type { ValuationLeadInput } from "@/lib/valuation/valuationLeadInput"; +import { sendMessage } from "@/lib/telegram/sendMessage"; +import { usd } from "@/lib/format/usd"; +import type { ValuationBand } from "@/lib/catalog/computeValuationBand"; + +export type CaptureValuationLeadInput = { + accountId: string; + artistName: string; + artistId: string; + /** The api valuation band ({ low, mid, high }); the lead's central = `mid`. */ + valueBand: ValuationBand; + lifetimeStreams?: number; + followerCount?: number; +}; + +/** + * Capture a valuation lead + team alert for a completed `POST /api/valuation` + * run — the server-side owner of the milestone, so **every** caller (chat, + * direct api, marketing funnel) captures a lead, not just the marketing frontend + * (chat#1885). Mirrors the marketing `/api/valuation/lead` route it replaces: + * upsert the Attio Person + pipeline card, then ping the internal Telegram + * channel with the email, artist, and value band. Two differences from the + * marketing route: the owner's email is resolved from the account (never the + * body), and the band arrives in api shape (`mid` is the central estimate). + * + * Best-effort and never throws — a lead/alert failure must never fail or block + * the valuation. Fired from runValuationHandler via `after()` once the catalog + * is materialized and the band computed. + */ +export async function captureValuationLead(input: CaptureValuationLeadInput): Promise { + try { + // Resolve the owning account's email — the lead is keyed on it, so no email + // means no lead to capture. + const emailRows = await selectAccountEmails({ accountIds: input.accountId }); + const email = emailRows.map(row => row.email).find((e): e is string => !!e); + if (!email) return; + + const lead: ValuationLeadInput = { + email, + artistName: input.artistName, + artistId: input.artistId, + valueBand: { + low: input.valueBand.low, + central: input.valueBand.mid, + high: input.valueBand.high, + }, + }; + if (typeof input.lifetimeStreams === "number") lead.lifetimeStreams = input.lifetimeStreams; + if (typeof input.followerCount === "number") lead.followerCount = input.followerCount; + + // Attio is the system of record. Don't block the ping on an Attio error — + // the valuation already succeeded — but log a dropped lead so it's observable. + const attio = await upsertValuationLead(lead); + if (!attio.success) { + console.error("[valuation/lead] Attio enrichment failed:", attio.error); + } + + // Deep-link the Attio record so the channel can open the lead in one tap. + const attioLink = attio.recordUrl ? `\nAttio: ${attio.recordUrl}` : ""; + await sendMessage( + `💰 Valuation lead\n` + + `Email: ${email}\n` + + `Artist: ${input.artistName}\n` + + `Estimated catalog value: ${usd(input.valueBand.mid)} ` + + `(range ${usd(input.valueBand.low)}–${usd(input.valueBand.high)})` + + attioLink, + ); + } catch (error) { + console.error("Valuation lead capture failed:", error); + } +} diff --git a/lib/valuation/leadAttributes.ts b/lib/valuation/leadAttributes.ts new file mode 100644 index 00000000..17186693 --- /dev/null +++ b/lib/valuation/leadAttributes.ts @@ -0,0 +1,22 @@ +import type { ValuationLeadInput } from "@/lib/valuation/valuationLeadInput"; + +/** + * Build the Attio Person attribute values for a valuation lead — the *latest* + * run's snapshot (the qualifying data sales sorts/filters on). `est_catalog_value` + * is the band's central estimate, i.e. the lead score. + */ +export function leadAttributes(lead: ValuationLeadInput, today: string): Record { + const values: Record = { + email_addresses: [{ email_address: lead.email }], + lead_source: "Catalog Valuation", + // Whole dollars — Attio's currency attribute rejects >4 decimal places, and + // the real valuation band is fractional. + est_catalog_value: Math.round(lead.valueBand.central), + looked_up_artist: lead.artistName, + spotify_artist_url: `https://open.spotify.com/artist/${lead.artistId}`, + valued_at: today, + }; + if (typeof lead.lifetimeStreams === "number") values.lifetime_streams = lead.lifetimeStreams; + if (typeof lead.followerCount === "number") values.follower_count = lead.followerCount; + return values; +} diff --git a/lib/valuation/leadNoteContent.ts b/lib/valuation/leadNoteContent.ts new file mode 100644 index 00000000..2a142bb0 --- /dev/null +++ b/lib/valuation/leadNoteContent.ts @@ -0,0 +1,23 @@ +import type { ValuationLeadInput } from "@/lib/valuation/valuationLeadInput"; +import { usd } from "@/lib/format/usd"; + +/** + * Markdown body for one run's activity note — the chronology entry attached to + * the Attio person on every valuation run. Lifetime streams and followers are + * only listed when known. + */ +export function leadNoteContent(lead: ValuationLeadInput, today: string): string { + const b = lead.valueBand; + const signals: string[] = []; + if (typeof lead.lifetimeStreams === "number") + signals.push(`- Lifetime streams: ${lead.lifetimeStreams.toLocaleString("en-US")}`); + if (typeof lead.followerCount === "number") + signals.push(`- Followers: ${lead.followerCount.toLocaleString("en-US")}`); + signals.push(`- Run date: ${today}`); + + return ( + `Valued [${lead.artistName}](https://open.spotify.com/artist/${lead.artistId}) at ` + + `**${usd(b.central)}** (range ${usd(b.low)}–${usd(b.high)}).\n\n` + + signals.join("\n") + ); +} diff --git a/lib/valuation/runValuationHandler.ts b/lib/valuation/runValuationHandler.ts index 792f16dd..fac1aa78 100644 --- a/lib/valuation/runValuationHandler.ts +++ b/lib/valuation/runValuationHandler.ts @@ -11,6 +11,7 @@ import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurem import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; +import { captureValuationLead } from "@/lib/valuation/captureValuationLead"; import { validateRunValuationRequest } from "./validateRunValuationRequest"; import { extractValuationAlbums } from "./extractValuationAlbums"; import { waitForSnapshotMeasurements } from "./waitForSnapshotMeasurements"; @@ -152,6 +153,22 @@ export async function runValuationHandler(request: NextRequest): Promise console.error("Valuation report email failed:", error)), ); + // Capture the lead + team Telegram alert for every valuation caller (chat, + // direct api, marketing funnel) — the shared handler owns this milestone now, + // not the marketing frontend (chat#1885). `after` so it never blocks the + // response; best-effort inside captureValuationLead. lifetimeStreams is the + // measured total the marketing funnel's client-side path never had. + after(() => + captureValuationLead({ + accountId, + artistName: searchedArtist?.name ?? "Unknown artist", + artistId: spotify_artist_id, + valueBand: valuation, + lifetimeStreams: aggregate?.totalStreams ?? undefined, + followerCount: searchedArtist?.followers?.total ?? undefined, + }).catch(error => console.error("Valuation lead capture failed:", error)), + ); + return successResponse({ catalog, band: valuation, diff --git a/lib/valuation/upsertValuationLead.ts b/lib/valuation/upsertValuationLead.ts new file mode 100644 index 00000000..fa50e97c --- /dev/null +++ b/lib/valuation/upsertValuationLead.ts @@ -0,0 +1,59 @@ +import type { ValuationLeadInput } from "@/lib/valuation/valuationLeadInput"; +import { leadAttributes } from "@/lib/valuation/leadAttributes"; +import { leadNoteContent } from "@/lib/valuation/leadNoteContent"; +import { assertPersonByEmail } from "@/lib/attio/assertPersonByEmail"; +import { createNote } from "@/lib/attio/createNote"; +import { isRecordInList } from "@/lib/attio/isRecordInList"; +import { addRecordToList } from "@/lib/attio/addRecordToList"; + +export type { ValuationLeadInput } from "@/lib/valuation/valuationLeadInput"; + +const ATTIO_WORKSPACE = "recoup"; +const LIST_SLUG = "valuation_leads"; +// Stable id of the "Valuation Leads" list — the per-record entries endpoint +// reports membership by list id, not slug. Attio is one workspace (shared by +// preview + prod), so this id is constant. +const LIST_ID = "f5abf9c0-b0a0-4d47-a6b1-37072e415e65"; + +/** + * Persist a valuation lead to Attio as a qualified, pipeline-staged record with + * a running activity log (chat#1885, ported from the marketing funnel). Orchestrates + * the Attio primitives in `lib/attio`: + * 1. assert the Person by email + qualifying attributes (latest run); + * 2. attach a timestamped Note — every run, incl. re-runs → a chronology; + * 3. add to the "Valuation Leads" pipeline at **New** only if not already on + * it (never duplicates a card or resets its stage). + * + * Best-effort and never throws. Returns the person-assert outcome plus a deep + * link to the record (for the Telegram ping). + */ +export async function upsertValuationLead( + lead: ValuationLeadInput, +): Promise<{ success: boolean; error?: string; recordUrl?: string }> { + if (!process.env.ATTIO_API_KEY) { + return { success: false, error: "ATTIO_API_KEY not configured" }; + } + + const today = new Date().toISOString().slice(0, 10); + const { recordId, error } = await assertPersonByEmail(leadAttributes(lead, today)); + if (error) return { success: false, error }; + if (!recordId) return { success: true }; + + const recordUrl = `https://app.attio.com/${ATTIO_WORKSPACE}/person/${recordId}/overview`; + + // Activity log: one note per run (every run, incl. re-runs) for a chronology. + await createNote({ + parentObject: "people", + parentRecordId: recordId, + title: `Catalog valuation — ${lead.artistName} (${today})`, + content: leadNoteContent(lead, today), + }); + + // Pipeline: add at New only if absent — re-runs must not duplicate the card + // or reset a stage sales already advanced. + if (!(await isRecordInList("people", recordId, LIST_ID))) { + await addRecordToList(LIST_SLUG, "people", recordId, { stage: ["New"] }); + } + + return { success: true, recordUrl }; +} diff --git a/lib/valuation/valuationLeadInput.ts b/lib/valuation/valuationLeadInput.ts new file mode 100644 index 00000000..d4575902 --- /dev/null +++ b/lib/valuation/valuationLeadInput.ts @@ -0,0 +1,13 @@ +/** The band shape a valuation lead is scored on — `central` is the lead score. */ +export type LeadBand = { low: number; central: number; high: number }; + +/** Server-side input for persisting a valuation lead to Attio (chat#1885). */ +export type ValuationLeadInput = { + email: string; + artistName: string; + artistId: string; + valueBand: LeadBand; + /** Lifetime stream total, when known. */ + lifetimeStreams?: number; + followerCount?: number; +};