Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/attio/__tests__/addRecordToList.test.ts
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();

Copy link
Copy Markdown

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.error spy into later tests because mockRestore() is only reached on the success path. Moving spy cleanup into an afterEach/finally path would preserve test isolation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/__tests__/addRecordToList.test.ts, line 33:

<comment>A failed assertion in this test can leak the `console.error` spy into later tests because `mockRestore()` is only reached on the success path. Moving spy cleanup into an `afterEach`/`finally` path would preserve test isolation.</comment>

<file context>
@@ -0,0 +1,35 @@
+      addRecordToList("valuation_leads", "people", "rec_1", { stage: ["New"] }),
+    ).resolves.toBeUndefined();
+    expect(errSpy).toHaveBeenCalled();
+    errSpy.mockRestore();
+  });
+});
</file context>

});
});
39 changes: 39 additions & 0 deletions lib/attio/__tests__/assertPersonByEmail.test.ts
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");
});
});
42 changes: 42 additions & 0 deletions lib/attio/__tests__/createNote.test.ts
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(() => {});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Spy on console.error is restored inline in the test body but not in afterEach, so a test failure before the restore line leaves the spy active across tests. Move restoration into afterEach so it runs regardless of pass/fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/__tests__/createNote.test.ts, line 37:

<comment>Spy on `console.error` is restored inline in the test body but not in `afterEach`, so a test failure before the restore line leaves the spy active across tests. Move restoration into `afterEach` so it runs regardless of pass/fail.</comment>

<file context>
@@ -0,0 +1,42 @@
+
+  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();
</file context>

await expect(createNote(note)).resolves.toBeUndefined();
expect(errSpy).toHaveBeenCalled();
errSpy.mockRestore();
});
});
33 changes: 33 additions & 0 deletions lib/attio/__tests__/isRecordInList.test.ts
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);
});
});
22 changes: 22 additions & 0 deletions lib/attio/addRecordToList.ts
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()}`);
}
}
20 changes: 20 additions & 0 deletions lib/attio/assertPersonByEmail.ts
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 };
}
28 changes: 28 additions & 0 deletions lib/attio/createNote.ts
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()}`);
}
}
20 changes: 20 additions & 0 deletions lib/attio/isRecordInList.ts
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`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 data.data before calling .some().

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/isRecordInList.ts, line 13:

<comment>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 `data.data` before calling `.some()`.</comment>

<file context>
@@ -0,0 +1,20 @@
+  recordId: string,
+  listId: string,
+): Promise<boolean> {
+  const res = await attioFetch(`/objects/${object}/records/${recordId}/entries`, {
+    method: "GET",
+  });
</file context>

method: "GET",
});
if (!res.ok) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 upsertValuationLead proceeds to addRecordToList and can create duplicate pipeline cards during an Attio outage or malformed response. Returning a separate verification error (or otherwise making the caller skip the add when the read cannot be trusted) would preserve deduplication.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/isRecordInList.ts, line 16:

<comment>An Attio membership-read failure is treated as “not in the list,” so `upsertValuationLead` proceeds to `addRecordToList` and can create duplicate pipeline cards during an Attio outage or malformed response. Returning a separate verification error (or otherwise making the caller skip the add when the read cannot be trusted) would preserve deduplication.</comment>

<file context>
@@ -0,0 +1,20 @@
+  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 ?? [];
</file context>

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);
}
17 changes: 17 additions & 0 deletions lib/attio/request.ts
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> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This Attio request helper is exported as attioFetch but lives in request.ts, which breaks the repository's required function-to-file naming convention. Moving it to lib/attio/attioFetch.ts keeps imports and module discovery consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/request.ts, line 8:

<comment>This Attio request helper is exported as `attioFetch` but lives in `request.ts`, which breaks the repository's required function-to-file naming convention. Moving it to `lib/attio/attioFetch.ts` keeps imports and module discovery consistent.</comment>

<file context>
@@ -0,0 +1,17 @@
+ * 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> {
+  return fetch(`${ATTIO_BASE_URL}${path}`, {
+    ...init,
</file context>

return fetch(`${ATTIO_BASE_URL}${path}`, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 upsertValuationLead promising best-effort behavior. Handling fetch rejections in the helper or each operation wrapper would let the flow log the Attio failure and continue.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/attio/request.ts, line 9:

<comment>A network rejection from Attio escapes the low-level helper and aborts the lead flow before its Telegram alert, despite the wrappers and `upsertValuationLead` promising best-effort behavior. Handling fetch rejections in the helper or each operation wrapper would let the flow log the Attio failure and continue.</comment>

<file context>
@@ -0,0 +1,17 @@
+ * Reads ATTIO_API_KEY at call time (server-only); returns the raw Response.
+ */
+export function attioFetch(path: string, init?: RequestInit): Promise<Response> {
+  return fetch(`${ATTIO_BASE_URL}${path}`, {
+    ...init,
+    headers: {
</file context>

...init,
headers: {
Authorization: `Bearer ${process.env.ATTIO_API_KEY}`,
"Content-Type": "application/json",
...init?.headers,
},
});
}
15 changes: 15 additions & 0 deletions lib/format/__tests__/usd.test.ts
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");
});
});
9 changes: 9 additions & 0 deletions lib/format/usd.ts
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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: NaN and ±Infinity pass through to produce "$NaN", "$∞", and "$-∞" — none of which are valid USD strings. A guard at the top of the function would make the contract defensive against unexpected inputs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/format/usd.ts, line 7:

<comment>`NaN` and `±Infinity` pass through to produce `"$NaN"`, `"$∞"`, and `"$-∞"` — none of which are valid USD strings. A guard at the top of the function would make the contract defensive against unexpected inputs.</comment>

<file context>
@@ -0,0 +1,9 @@
+ * 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")}`;
+}
</file context>

return `$${Math.round(n).toLocaleString("en-US")}`;
}
94 changes: 94 additions & 0 deletions lib/valuation/__tests__/captureValuationLead.test.ts
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");
});
});
Loading
Loading