From 87e7402c23bc9c714dd71e753086340e47391ae0 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 20 Jul 2026 15:30:13 -0500 Subject: [PATCH 1/9] feat(emails): valuation-report email when a snapshot run completes When a playcount snapshot run reaches its terminal done state, email the owning account a deterministic valuation summary (recoupable/chat#1867): headline valuation band (same computeValuationBand model as the catalog measurements endpoint), lifetime Spotify streams, songs/albums measured, and a deep link to chat.recoupable.dev/catalogs/{id} (app root when no catalog was claimed). House-style template mirroring the scrape digest (DESIGN.md achromatic chrome, tables + inline styles). Idempotent per snapshot run: a snapshot_id marker in email_send_log raw_body guards re-invocations, plus a Resend idempotency key (valuation-report/) for racing retries within 24h. The send step is best-effort and never fails a completed run; accounts with no email are skipped and every attempt is logged to email_send_log. Co-Authored-By: Claude Fable 5 --- .../__tests__/sendValuationEmailStep.test.ts | 32 +++++ app/workflows/playcountSnapshotWorkflow.ts | 2 + app/workflows/sendValuationEmailStep.ts | 23 ++++ .../__tests__/formatCompactNumber.test.ts | 24 ++++ .../renderValuationReportHtml.test.ts | 62 +++++++++ .../sendValuationReportEmail.test.ts | 125 ++++++++++++++++++ .../valuationReport/formatCompactNumber.ts | 16 +++ .../renderValuationReportHtml.ts | 83 ++++++++++++ .../sendValuationReportEmail.ts | 103 +++++++++++++++ .../selectValuationEmailSendLog.test.ts | 46 +++++++ .../selectValuationEmailSendLog.ts | 30 +++++ 11 files changed, 546 insertions(+) create mode 100644 app/workflows/__tests__/sendValuationEmailStep.test.ts create mode 100644 app/workflows/sendValuationEmailStep.ts create mode 100644 lib/emails/valuationReport/__tests__/formatCompactNumber.test.ts create mode 100644 lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts create mode 100644 lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts create mode 100644 lib/emails/valuationReport/formatCompactNumber.ts create mode 100644 lib/emails/valuationReport/renderValuationReportHtml.ts create mode 100644 lib/emails/valuationReport/sendValuationReportEmail.ts create mode 100644 lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts create mode 100644 lib/supabase/email_send_log/selectValuationEmailSendLog.ts diff --git a/app/workflows/__tests__/sendValuationEmailStep.test.ts b/app/workflows/__tests__/sendValuationEmailStep.test.ts new file mode 100644 index 00000000..c35d8c09 --- /dev/null +++ b/app/workflows/__tests__/sendValuationEmailStep.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendValuationEmailStep } from "../sendValuationEmailStep"; +import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; +import type { Tables } from "@/types/database.types"; + +vi.mock("@/lib/emails/valuationReport/sendValuationReportEmail", () => ({ + sendValuationReportEmail: vi.fn(), +})); + +const snapshot = { id: "snap_1", account: "acc_1" } as Tables<"playcount_snapshots">; + +describe("sendValuationEmailStep", () => { + beforeEach(() => vi.clearAllMocks()); + + it("delegates to sendValuationReportEmail", async () => { + vi.mocked(sendValuationReportEmail).mockResolvedValue({ sent: true, resendId: "resend_1" }); + + await sendValuationEmailStep(snapshot); + + expect(sendValuationReportEmail).toHaveBeenCalledWith(snapshot); + }); + + it("never throws: an email failure must not fail a completed snapshot", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(sendValuationReportEmail).mockRejectedValue(new Error("resend down")); + + await expect(sendValuationEmailStep(snapshot)).resolves.toBeUndefined(); + + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); diff --git a/app/workflows/playcountSnapshotWorkflow.ts b/app/workflows/playcountSnapshotWorkflow.ts index 8b6c38bc..09a30b56 100644 --- a/app/workflows/playcountSnapshotWorkflow.ts +++ b/app/workflows/playcountSnapshotWorkflow.ts @@ -1,5 +1,6 @@ import { getSnapshotStep } from "@/app/workflows/getSnapshotStep"; import { markSnapshotStep } from "@/app/workflows/markSnapshotStep"; +import { sendValuationEmailStep } from "@/app/workflows/sendValuationEmailStep"; import { fetchChunkStep } from "@/app/workflows/fetchChunkStep"; import { mapAndWriteChunkStep } from "@/app/workflows/mapAndWriteChunkStep"; @@ -29,6 +30,7 @@ export async function playcountSnapshotWorkflow(snapshotId: string) { } await markSnapshotStep(snapshotId, { state: "done" }); + await sendValuationEmailStep(snapshot); return { success: true as const, measurementsWritten: written }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/app/workflows/sendValuationEmailStep.ts b/app/workflows/sendValuationEmailStep.ts new file mode 100644 index 00000000..7d8aacab --- /dev/null +++ b/app/workflows/sendValuationEmailStep.ts @@ -0,0 +1,23 @@ +import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; +import type { Tables } from "@/types/database.types"; + +/** + * Best-effort valuation-report email after a snapshot run reaches `done` + * (recoupable/chat#1867). Runs as its own step so the send is memoized across + * workflow retries, and never throws: by this point the measurements are + * written and the run is marked done, so an email failure must not flip a + * successful run to failed. + * + * @param snapshot - The completed snapshot row (loaded by getSnapshotStep) + */ +export async function sendValuationEmailStep( + snapshot: Tables<"playcount_snapshots">, +): Promise { + "use step"; + try { + const result = await sendValuationReportEmail(snapshot); + console.log(`[playcount-snapshot] Valuation email for ${snapshot.id}:`, result); + } catch (error) { + console.error(`[playcount-snapshot] Valuation email failed for ${snapshot.id}:`, error); + } +} diff --git a/lib/emails/valuationReport/__tests__/formatCompactNumber.test.ts b/lib/emails/valuationReport/__tests__/formatCompactNumber.test.ts new file mode 100644 index 00000000..b3c51256 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/formatCompactNumber.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest"; +import { formatCompactNumber } from "../formatCompactNumber"; + +describe("formatCompactNumber", () => { + it("passes small numbers through, rounded to integers", () => { + expect(formatCompactNumber(0)).toBe("0"); + expect(formatCompactNumber(678)).toBe("678"); + expect(formatCompactNumber(999.6)).toBe("1K"); + }); + + it("formats thousands with one decimal, trailing .0 stripped", () => { + expect(formatCompactNumber(12_345)).toBe("12.3K"); + expect(formatCompactNumber(10_000)).toBe("10K"); + }); + + it("formats millions", () => { + expect(formatCompactNumber(1_200_000)).toBe("1.2M"); + expect(formatCompactNumber(348_000_000)).toBe("348M"); + }); + + it("formats billions (large catalog lifetime streams)", () => { + expect(formatCompactNumber(1_400_000_000)).toBe("1.4B"); + }); +}); diff --git a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts new file mode 100644 index 00000000..49534d90 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { renderValuationReportHtml } from "../renderValuationReportHtml"; + +const baseParams = { + catalogName: "Epitaph 10-Album Catalog", + deepLinkUrl: "https://chat.recoupable.dev/catalogs/cat_1", + albumCount: 10, + valuation: { low: 7_200, mid: 10_400, high: 14_100 }, + totalStreams: 3_480_000, + measuredSongCount: 112, + catalogAgeYears: 5, +}; + +describe("renderValuationReportHtml", () => { + it("uses the fixed subject line", () => { + const { subject } = renderValuationReportHtml(baseParams); + expect(subject).toBe("Your catalog valuation is ready"); + }); + + it("renders catalog name, headline value, range, streams, and counts", () => { + const { html } = renderValuationReportHtml(baseParams); + expect(html).toContain("Epitaph 10-Album Catalog"); + expect(html).toContain("$10.4K"); + expect(html).toContain("$7.2K to $14.1K"); + expect(html).toContain("3.5M"); + expect(html).toContain("112"); + expect(html).toContain("10"); + }); + + it("links the CTA to the deep link", () => { + const { html } = renderValuationReportHtml(baseParams); + expect(html).toContain('href="https://chat.recoupable.dev/catalogs/cat_1"'); + }); + + it("escapes HTML in the catalog name", () => { + const { html } = renderValuationReportHtml({ + ...baseParams, + catalogName: 'Catalog', + }); + expect(html).not.toContain(''); + expect(html).toContain("<img src="x">Catalog"); + }); + + it("omits the valuation and streams rows when no measurements are available", () => { + const { html } = renderValuationReportHtml({ + catalogName: null, + deepLinkUrl: "https://chat.recoupable.dev", + albumCount: 4, + }); + expect(html).not.toContain("Estimated value"); + expect(html).not.toContain("Lifetime Spotify streams"); + expect(html).toContain("Your catalog"); + expect(html).toContain('href="https://chat.recoupable.dev"'); + expect(html).toContain("4"); + }); + + it("contains no em or en dashes in outward-facing copy", () => { + const { html, subject } = renderValuationReportHtml(baseParams); + expect(subject).not.toMatch(/[–—]/); + expect(html).not.toMatch(/[–—]/); + }); +}); diff --git a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts new file mode 100644 index 00000000..21c9fbe7 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextResponse } from "next/server"; +import { sendValuationReportEmail } from "../sendValuationReportEmail"; +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; +import { selectValuationEmailSendLog } from "@/lib/supabase/email_send_log/selectValuationEmailSendLog"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import type { Tables } from "@/types/database.types"; + +vi.mock("@/lib/emails/sendEmail", () => ({ sendEmailWithResend: vi.fn() })); +vi.mock("@/lib/emails/logEmailAttempt", () => ({ logEmailAttempt: vi.fn() })); +vi.mock("@/lib/supabase/email_send_log/selectValuationEmailSendLog", () => ({ + selectValuationEmailSendLog: vi.fn(), +})); +vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn() })); +vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.fn() })); +vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate", () => ({ + selectCatalogMeasurementsAggregate: vi.fn(), +})); +vi.mock("@/lib/catalog/getCatalogEarliestReleaseDate", () => ({ + getCatalogEarliestReleaseDate: vi.fn(), +})); + +const snapshot = { + id: "snap_1", + account: "acc_1", + catalog: "cat_1", + album_count: 10, + state: "done", +} as Tables<"playcount_snapshots">; + +function arrange() { + vi.mocked(selectValuationEmailSendLog).mockResolvedValue(null); + vi.mocked(selectAccountEmails).mockResolvedValue([ + { email: "digital@epitaph.com", account_id: "acc_1" } as Tables<"account_emails">, + ]); + vi.mocked(selectCatalogById).mockResolvedValue({ + id: "cat_1", + name: "Epitaph Catalog", + } as Tables<"catalogs">); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 112, + totalStreams: 3_480_000, + }); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-01-01"); + vi.mocked(sendEmailWithResend).mockResolvedValue({ id: "resend_1" }); +} + +describe("sendValuationReportEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + arrange(); + }); + + it("sends the report with a per-snapshot idempotency key and logs the send", async () => { + const result = await sendValuationReportEmail(snapshot); + + expect(sendEmailWithResend).toHaveBeenCalledTimes(1); + const [payload, options] = vi.mocked(sendEmailWithResend).mock.calls[0]; + expect(payload.from).toBe(RECOUP_FROM_EMAIL); + expect(payload.to).toEqual(["digital@epitaph.com"]); + expect(payload.subject).toBe("Your catalog valuation is ready"); + expect(payload.html).toContain("Epitaph Catalog"); + expect(payload.html).toContain("https://chat.recoupable.dev/catalogs/cat_1"); + expect(options).toEqual({ idempotencyKey: "valuation-report/snap_1" }); + + expect(logEmailAttempt).toHaveBeenCalledTimes(1); + const attempt = vi.mocked(logEmailAttempt).mock.calls[0][0]; + expect(attempt.status).toBe("sent"); + expect(attempt.accountId).toBe("acc_1"); + expect(attempt.resendId).toBe("resend_1"); + expect(attempt.rawBody).toContain('"snapshot_id":"snap_1"'); + expect(attempt.rawBody).toContain('"type":"valuation_report"'); + + expect(result).toEqual({ sent: true, resendId: "resend_1" }); + }); + + it("skips without sending when a send is already logged for this snapshot", async () => { + vi.mocked(selectValuationEmailSendLog).mockResolvedValue({ + id: "log_1", + } as Tables<"email_send_log">); + + const result = await sendValuationReportEmail(snapshot); + + expect(sendEmailWithResend).not.toHaveBeenCalled(); + expect(logEmailAttempt).not.toHaveBeenCalled(); + expect(result).toEqual({ sent: false, skipped: "already_sent" }); + }); + + it("skips when the account has no email address", async () => { + vi.mocked(selectAccountEmails).mockResolvedValue([]); + + const result = await sendValuationReportEmail(snapshot); + + expect(sendEmailWithResend).not.toHaveBeenCalled(); + expect(result).toEqual({ sent: false, skipped: "no_email" }); + }); + + it("falls back to the app link and album count when no catalog was claimed", async () => { + const result = await sendValuationReportEmail({ ...snapshot, catalog: null }); + + expect(selectCatalogById).not.toHaveBeenCalled(); + expect(selectCatalogMeasurementsAggregate).not.toHaveBeenCalled(); + const [payload] = vi.mocked(sendEmailWithResend).mock.calls[0]; + expect(payload.html).toContain('href="https://chat.recoupable.dev"'); + expect(payload.html).not.toContain("/catalogs/"); + expect(result).toEqual({ sent: true, resendId: "resend_1" }); + }); + + it("logs send_failed and reports the failure when Resend rejects the send", async () => { + vi.mocked(sendEmailWithResend).mockResolvedValue( + NextResponse.json({ error: "Failed to send email" }, { status: 502 }), + ); + + const result = await sendValuationReportEmail(snapshot); + + const attempt = vi.mocked(logEmailAttempt).mock.calls[0][0]; + expect(attempt.status).toBe("send_failed"); + expect(result).toEqual({ sent: false, error: "Failed to send email" }); + }); +}); diff --git a/lib/emails/valuationReport/formatCompactNumber.ts b/lib/emails/valuationReport/formatCompactNumber.ts new file mode 100644 index 00000000..0999f260 --- /dev/null +++ b/lib/emails/valuationReport/formatCompactNumber.ts @@ -0,0 +1,16 @@ +/** + * Deterministic compact number for the valuation email: 678, 12.3K, 1.2M, + * 1.4B (trailing .0 stripped). Extends the digest's K/M formatter with a B + * tier because lifetime catalog stream totals routinely cross a billion. + */ +export function formatCompactNumber(n: number): string { + const rounded = Math.round(n); + if (rounded < 1000) return String(rounded); + const [value, unit] = + rounded < 1_000_000 + ? [rounded / 1000, "K"] + : rounded < 1_000_000_000 + ? [rounded / 1_000_000, "M"] + : [rounded / 1_000_000_000, "B"]; + return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`; +} diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts new file mode 100644 index 00000000..c79bc7d7 --- /dev/null +++ b/lib/emails/valuationReport/renderValuationReportHtml.ts @@ -0,0 +1,83 @@ +import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; + +export type ValuationReportEmailParams = { + catalogName: string | null; + deepLinkUrl: string; + albumCount: number; + valuation?: { low: number; mid: number; high: number }; + totalStreams?: number; + measuredSongCount?: number; + catalogAgeYears?: number; +}; + +const SUBJECT = "Your catalog valuation is ready"; + +/** + * Deterministic house-style renderer for the valuation-report email + * (recoupable/chat#1867): the summary a signup can return to after closing + * the tab. Styling follows DESIGN.md, mirroring the scrape-digest sibling — + * achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), + * tables + inline styles only, system font stack, fixed CHAT_APP_URL-based + * deep link (never a derived deployment URL). Copy avoids em/en dashes and + * uses "to" for the valuation range. + */ +export function renderValuationReportHtml(params: ValuationReportEmailParams): { + subject: string; + html: string; +} { + const name = params.catalogName ? escapeHtml(params.catalogName) : "Your catalog"; + + const valuationBlock = params.valuation + ? `
+

Estimated value

+

$${formatCompactNumber(params.valuation.mid)}

+

Range: $${formatCompactNumber(params.valuation.low)} to $${formatCompactNumber(params.valuation.high)}

+
` + : ""; + + const statRows = [ + params.totalStreams != null + ? ["Lifetime Spotify streams", formatCompactNumber(params.totalStreams)] + : null, + params.measuredSongCount != null + ? ["Songs measured", formatCompactNumber(params.measuredSongCount)] + : null, + ["Albums measured", formatCompactNumber(params.albumCount)], + params.catalogAgeYears != null + ? ["Catalog age", `${params.catalogAgeYears} year${params.catalogAgeYears === 1 ? "" : "s"}`] + : null, + ] + .filter((row): row is [string, string] => row !== null) + .map( + ([label, value]) => + `${label}${value}`, + ) + .join(""); + + const methodNote = params.valuation + ? `

Estimate derived from lifetime Spotify play counts using a 10 to 16x net-royalty multiple. Not a formal appraisal.

` + : ""; + + const html = `
+ +
+ + + +
+

Catalog valuation

+

${name}

+

Your measurement run is complete.

+
Recoup
+${valuationBlock} +${statRows}
+
View your full report →
+${methodNote} +

You're receiving this because you ran a catalog valuation on Recoup.

+
+
`; + + return { subject: SUBJECT, html }; +} diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts new file mode 100644 index 00000000..a0af85db --- /dev/null +++ b/lib/emails/valuationReport/sendValuationReportEmail.ts @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { CHAT_APP_URL, RECOUP_FROM_EMAIL } from "@/lib/const"; +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; +import { renderValuationReportHtml } from "@/lib/emails/valuationReport/renderValuationReportHtml"; +import { selectValuationEmailSendLog } from "@/lib/supabase/email_send_log/selectValuationEmailSendLog"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; +import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/renderValuationReportHtml"; +import type { Tables } from "@/types/database.types"; + +export type SendValuationReportEmailResult = + | { sent: true; resendId: string } + | { sent: false; skipped: "already_sent" | "no_email" } + | { sent: false; error: string }; + +/** + * Emails the valuation summary for a completed snapshot run to the owning + * account (recoupable/chat#1867): headline valuation band + lifetime streams + * + measured counts, deep-linked to the catalog report on chat. Idempotent + * per run twice over: a `"snapshot_id"` marker in `email_send_log.raw_body` + * guards re-invocations for as long as the log exists, and the Resend + * idempotency key (`valuation-report/`) guards racing retries within + * Resend's 24h window. Skips silently when the account has no email. + * Measurement enrichment is best-effort: a read failure degrades to the + * link-only email rather than dropping the send. + * + * @param snapshot - The completed playcount_snapshots row + */ +export async function sendValuationReportEmail( + snapshot: Tables<"playcount_snapshots">, +): Promise { + if (await selectValuationEmailSendLog(snapshot.id)) { + return { sent: false, skipped: "already_sent" }; + } + + const emailRows = await selectAccountEmails({ accountIds: snapshot.account }); + const emails = [...new Set(emailRows.map(row => row.email).filter((e): e is string => !!e))]; + if (emails.length === 0) { + return { sent: false, skipped: "no_email" }; + } + + const params: ValuationReportEmailParams = { + catalogName: null, + deepLinkUrl: snapshot.catalog ? `${CHAT_APP_URL}/catalogs/${snapshot.catalog}` : CHAT_APP_URL, + albumCount: snapshot.album_count ?? snapshot.album_ids?.length ?? 0, + }; + + if (snapshot.catalog) { + try { + const [catalog, aggregate, earliestReleaseDate] = await Promise.all([ + selectCatalogById(snapshot.catalog), + selectCatalogMeasurementsAggregate({ catalogId: snapshot.catalog }), + getCatalogEarliestReleaseDate(snapshot.catalog), + ]); + params.catalogName = catalog?.name ?? null; + if (aggregate && aggregate.totalStreams > 0) { + const { valuation, catalogAgeYears } = computeValuationBand({ + totalStreams: aggregate.totalStreams, + earliestReleaseDate, + }); + params.valuation = valuation; + params.totalStreams = aggregate.totalStreams; + params.measuredSongCount = aggregate.measuredSongCount; + params.catalogAgeYears = catalogAgeYears; + } + } catch (error) { + console.error(`Valuation email enrichment failed for snapshot ${snapshot.id}:`, error); + } + } + + const { subject, html } = renderValuationReportHtml(params); + const rawBody = JSON.stringify({ + type: "valuation_report", + snapshot_id: snapshot.id, + catalog: snapshot.catalog, + to: emails, + subject, + }); + + const result = await sendEmailWithResend( + { from: RECOUP_FROM_EMAIL, to: emails, subject, html }, + { idempotencyKey: `valuation-report/${snapshot.id}` }, + ); + + if (result instanceof NextResponse) { + await logEmailAttempt({ rawBody, status: "send_failed", accountId: snapshot.account }); + const data = await result.json().catch(() => null); + const message = typeof data?.error === "string" ? data.error : "Failed to send email"; + return { sent: false, error: message }; + } + + await logEmailAttempt({ + rawBody, + status: "sent", + accountId: snapshot.account, + resendId: result.id, + }); + return { sent: true, resendId: result.id }; +} diff --git a/lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts b/lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts new file mode 100644 index 00000000..72adf672 --- /dev/null +++ b/lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectValuationEmailSendLog } from "../selectValuationEmailSendLog"; +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => { + const mockFrom = vi.fn(); + return { default: { from: mockFrom } }; +}); + +function mockBuilder(result: { data: unknown; error: unknown }) { + const builder: Record> & { + then?: (resolve: (v: unknown) => void) => void; + } = {} as never; + for (const m of ["select", "eq", "like", "limit"]) builder[m] = vi.fn().mockReturnValue(builder); + builder.then = resolve => resolve(result); + vi.mocked(supabase.from).mockReturnValue(builder as never); + return builder; +} + +describe("selectValuationEmailSendLog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("matches sent rows carrying this snapshot's marker", async () => { + const rows = [{ id: "log_1", status: "sent" }]; + const builder = mockBuilder({ data: rows, error: null }); + + const result = await selectValuationEmailSendLog("snap_1"); + + expect(supabase.from).toHaveBeenCalledWith("email_send_log"); + expect(builder.eq).toHaveBeenCalledWith("status", "sent"); + expect(builder.like).toHaveBeenCalledWith("raw_body", '%"snapshot_id":"snap_1"%'); + expect(result).toEqual(rows[0]); + }); + + it("returns null when no send is recorded", async () => { + mockBuilder({ data: [], error: null }); + expect(await selectValuationEmailSendLog("snap_1")).toBeNull(); + }); + + it("returns null on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mockBuilder({ data: null, error: { message: "boom" } }); + expect(await selectValuationEmailSendLog("snap_1")).toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/email_send_log/selectValuationEmailSendLog.ts b/lib/supabase/email_send_log/selectValuationEmailSendLog.ts new file mode 100644 index 00000000..640b91a6 --- /dev/null +++ b/lib/supabase/email_send_log/selectValuationEmailSendLog.ts @@ -0,0 +1,30 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Finds a prior successful valuation-report send for a snapshot run, keyed on + * the `"snapshot_id":""` marker that sendValuationReportEmail writes into + * `raw_body`. This is the long-window idempotency guard (Resend's idempotency + * key only covers 24h): a matching row means the report was already delivered + * for this run and must not be re-sent. + * + * @param snapshotId - The playcount_snapshots run id + * @returns The matching sent row, or null if none exists or on error + */ +export async function selectValuationEmailSendLog( + snapshotId: string, +): Promise | null> { + const { data, error } = await supabase + .from("email_send_log") + .select("*") + .eq("status", "sent") + .like("raw_body", `%"snapshot_id":"${snapshotId}"%`) + .limit(1); + + if (error) { + console.error("Error fetching email_send_log for snapshot:", error); + return null; + } + + return data?.[0] ?? null; +} From de0959123457ae393e1b28ae576d40c17cf9561e Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 22 Jul 2026 18:15:32 -0500 Subject: [PATCH 2/9] refactor(emails): replace selectValuationEmailSendLog with a generic selectEmailSendLog (KISS, review feedback) Per PR review: instead of a valuation-specific email_send_log select, use one generic selectEmailSendLog({ status, rawBodyLike, limit }) and pass the filters. sendValuationReportEmail's dedup guard now calls it with the snapshot_id marker. Reusable for other email-type idempotency checks. Tests updated (generic select 4/4, sendValuationReportEmail 5/5). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../sendValuationReportEmail.test.ts | 12 +++--- .../sendValuationReportEmail.ts | 11 +++++- ...Log.test.ts => selectEmailSendLog.test.ts} | 33 ++++++++++++----- .../email_send_log/selectEmailSendLog.ts | 37 +++++++++++++++++++ .../selectValuationEmailSendLog.ts | 30 --------------- 5 files changed, 75 insertions(+), 48 deletions(-) rename lib/supabase/email_send_log/__tests__/{selectValuationEmailSendLog.test.ts => selectEmailSendLog.test.ts} (56%) create mode 100644 lib/supabase/email_send_log/selectEmailSendLog.ts delete mode 100644 lib/supabase/email_send_log/selectValuationEmailSendLog.ts diff --git a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts index 21c9fbe7..a93043de 100644 --- a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts +++ b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts @@ -3,7 +3,7 @@ import { NextResponse } from "next/server"; import { sendValuationReportEmail } from "../sendValuationReportEmail"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; -import { selectValuationEmailSendLog } from "@/lib/supabase/email_send_log/selectValuationEmailSendLog"; +import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; @@ -13,8 +13,8 @@ import type { Tables } from "@/types/database.types"; vi.mock("@/lib/emails/sendEmail", () => ({ sendEmailWithResend: vi.fn() })); vi.mock("@/lib/emails/logEmailAttempt", () => ({ logEmailAttempt: vi.fn() })); -vi.mock("@/lib/supabase/email_send_log/selectValuationEmailSendLog", () => ({ - selectValuationEmailSendLog: vi.fn(), +vi.mock("@/lib/supabase/email_send_log/selectEmailSendLog", () => ({ + selectEmailSendLog: vi.fn(), })); vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ default: vi.fn() })); vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.fn() })); @@ -34,7 +34,7 @@ const snapshot = { } as Tables<"playcount_snapshots">; function arrange() { - vi.mocked(selectValuationEmailSendLog).mockResolvedValue(null); + vi.mocked(selectEmailSendLog).mockResolvedValue([]); vi.mocked(selectAccountEmails).mockResolvedValue([ { email: "digital@epitaph.com", account_id: "acc_1" } as Tables<"account_emails">, ]); @@ -80,9 +80,7 @@ describe("sendValuationReportEmail", () => { }); it("skips without sending when a send is already logged for this snapshot", async () => { - vi.mocked(selectValuationEmailSendLog).mockResolvedValue({ - id: "log_1", - } as Tables<"email_send_log">); + vi.mocked(selectEmailSendLog).mockResolvedValue([{ id: "log_1" } as Tables<"email_send_log">]); const result = await sendValuationReportEmail(snapshot); diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts index a0af85db..62b60922 100644 --- a/lib/emails/valuationReport/sendValuationReportEmail.ts +++ b/lib/emails/valuationReport/sendValuationReportEmail.ts @@ -3,7 +3,7 @@ import { CHAT_APP_URL, RECOUP_FROM_EMAIL } from "@/lib/const"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; import { renderValuationReportHtml } from "@/lib/emails/valuationReport/renderValuationReportHtml"; -import { selectValuationEmailSendLog } from "@/lib/supabase/email_send_log/selectValuationEmailSendLog"; +import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; @@ -33,7 +33,14 @@ export type SendValuationReportEmailResult = export async function sendValuationReportEmail( snapshot: Tables<"playcount_snapshots">, ): Promise { - if (await selectValuationEmailSendLog(snapshot.id)) { + // Long-window idempotency: a prior successful send for this run is marked by + // the `"snapshot_id":""` marker in raw_body (Resend's key only covers 24h). + const alreadySent = await selectEmailSendLog({ + status: "sent", + rawBodyLike: `"snapshot_id":"${snapshot.id}"`, + limit: 1, + }); + if (alreadySent.length > 0) { return { sent: false, skipped: "already_sent" }; } diff --git a/lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts similarity index 56% rename from lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts rename to lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts index 72adf672..97283808 100644 --- a/lib/supabase/email_send_log/__tests__/selectValuationEmailSendLog.test.ts +++ b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { selectValuationEmailSendLog } from "../selectValuationEmailSendLog"; +import { selectEmailSendLog } from "../selectEmailSendLog"; import supabase from "../../serverClient"; vi.mock("../../serverClient", () => { @@ -17,30 +17,45 @@ function mockBuilder(result: { data: unknown; error: unknown }) { return builder; } -describe("selectValuationEmailSendLog", () => { +describe("selectEmailSendLog", () => { beforeEach(() => vi.clearAllMocks()); - it("matches sent rows carrying this snapshot's marker", async () => { + it("applies the status, raw_body-substring, and limit filters", async () => { const rows = [{ id: "log_1", status: "sent" }]; const builder = mockBuilder({ data: rows, error: null }); - const result = await selectValuationEmailSendLog("snap_1"); + const result = await selectEmailSendLog({ + status: "sent", + rawBodyLike: '"snapshot_id":"snap_1"', + limit: 1, + }); expect(supabase.from).toHaveBeenCalledWith("email_send_log"); expect(builder.eq).toHaveBeenCalledWith("status", "sent"); expect(builder.like).toHaveBeenCalledWith("raw_body", '%"snapshot_id":"snap_1"%'); - expect(result).toEqual(rows[0]); + expect(builder.limit).toHaveBeenCalledWith(1); + expect(result).toEqual(rows); }); - it("returns null when no send is recorded", async () => { + it("skips filters that aren't provided", async () => { + const builder = mockBuilder({ data: [], error: null }); + + await selectEmailSendLog(); + + expect(builder.eq).not.toHaveBeenCalled(); + expect(builder.like).not.toHaveBeenCalled(); + expect(builder.limit).not.toHaveBeenCalled(); + }); + + it("returns an empty array when nothing matches", async () => { mockBuilder({ data: [], error: null }); - expect(await selectValuationEmailSendLog("snap_1")).toBeNull(); + expect(await selectEmailSendLog({ status: "sent" })).toEqual([]); }); - it("returns null on error", async () => { + it("returns an empty array on error", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); mockBuilder({ data: null, error: { message: "boom" } }); - expect(await selectValuationEmailSendLog("snap_1")).toBeNull(); + expect(await selectEmailSendLog({ status: "sent" })).toEqual([]); consoleError.mockRestore(); }); }); diff --git a/lib/supabase/email_send_log/selectEmailSendLog.ts b/lib/supabase/email_send_log/selectEmailSendLog.ts new file mode 100644 index 00000000..0c458ada --- /dev/null +++ b/lib/supabase/email_send_log/selectEmailSendLog.ts @@ -0,0 +1,37 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** Optional filters for a generic `email_send_log` read. */ +export interface SelectEmailSendLogFilters { + /** Exact match on `status` (e.g. "sent"). */ + status?: string; + /** Substring matched within `raw_body` (LIKE `%value%`) — pass the marker the send was keyed on. */ + rawBodyLike?: string; + /** Cap the number of rows returned. */ + limit?: number; +} + +/** + * Generic read of `email_send_log` with optional filters — reused for any + * email-type idempotency/dedup check (pass the `raw_body` marker the send was + * keyed on). KISS: one query builder instead of a per-email-type select. + * + * @param filters - Optional status / raw_body-substring / limit filters + * @returns Matching rows (empty array on error or no match) + */ +export async function selectEmailSendLog( + filters: SelectEmailSendLogFilters = {}, +): Promise[]> { + let query = supabase.from("email_send_log").select("*"); + + if (filters.status) query = query.eq("status", filters.status); + if (filters.rawBodyLike) query = query.like("raw_body", `%${filters.rawBodyLike}%`); + if (filters.limit) query = query.limit(filters.limit); + + const { data, error } = await query; + if (error) { + console.error("Error fetching email_send_log:", error); + return []; + } + return data ?? []; +} diff --git a/lib/supabase/email_send_log/selectValuationEmailSendLog.ts b/lib/supabase/email_send_log/selectValuationEmailSendLog.ts deleted file mode 100644 index 640b91a6..00000000 --- a/lib/supabase/email_send_log/selectValuationEmailSendLog.ts +++ /dev/null @@ -1,30 +0,0 @@ -import supabase from "../serverClient"; -import type { Tables } from "@/types/database.types"; - -/** - * Finds a prior successful valuation-report send for a snapshot run, keyed on - * the `"snapshot_id":""` marker that sendValuationReportEmail writes into - * `raw_body`. This is the long-window idempotency guard (Resend's idempotency - * key only covers 24h): a matching row means the report was already delivered - * for this run and must not be re-sent. - * - * @param snapshotId - The playcount_snapshots run id - * @returns The matching sent row, or null if none exists or on error - */ -export async function selectValuationEmailSendLog( - snapshotId: string, -): Promise | null> { - const { data, error } = await supabase - .from("email_send_log") - .select("*") - .eq("status", "sent") - .like("raw_body", `%"snapshot_id":"${snapshotId}"%`) - .limit(1); - - if (error) { - console.error("Error fetching email_send_log for snapshot:", error); - return null; - } - - return data?.[0] ?? null; -} From e984eec9e92e8d33366f556ec7e63d346070acff Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 22 Jul 2026 19:05:33 -0500 Subject: [PATCH 3/9] feat(valuation-email): add buildReleaseRollups (per-album streams, mirrors chat report) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildReleaseRollups.test.ts | 80 ++++++++++++++++++ lib/catalog/buildReleaseRollups.ts | Bin 0 -> 2350 bytes 2 files changed, 80 insertions(+) create mode 100644 lib/catalog/__tests__/buildReleaseRollups.test.ts create mode 100644 lib/catalog/buildReleaseRollups.ts diff --git a/lib/catalog/__tests__/buildReleaseRollups.test.ts b/lib/catalog/__tests__/buildReleaseRollups.test.ts new file mode 100644 index 00000000..0ed64dbe --- /dev/null +++ b/lib/catalog/__tests__/buildReleaseRollups.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { buildReleaseRollups } from "../buildReleaseRollups"; + +describe("buildReleaseRollups", () => { + it("groups songs by album, joins play counts by ISRC, and sorts by streams desc", () => { + const songs = [ + { album: "Album A", isrc: "US1", artists: [{ name: "Nova" }] }, + { album: "Album A", isrc: "US2", artists: [{ name: "Nova" }, { name: "Guest" }] }, + { album: "Album B", isrc: "US3", artists: [{ name: "Nova" }] }, + ]; + const measurements = [ + { isrc: "US1", playcount: 100 }, + { isrc: "US2", playcount: 200 }, + { isrc: "US3", playcount: 5000 }, + ]; + + const rollups = buildReleaseRollups(songs, measurements); + + expect(rollups).toEqual([ + { + album: "Album B", + artistNames: ["Nova"], + trackCount: 1, + measuredTrackCount: 1, + streams: 5000, + }, + { + album: "Album A", + artistNames: ["Nova", "Guest"], + trackCount: 2, + measuredTrackCount: 2, + streams: 300, + }, + ]); + }); + + it("proportional shares of the band sum back to the headline (no divergence)", () => { + const songs = [ + { album: "A", isrc: "US1", artists: [] }, + { album: "B", isrc: "US2", artists: [] }, + ]; + const measurements = [ + { isrc: "US1", playcount: 300 }, + { isrc: "US2", playcount: 700 }, + ]; + const bandMid = 1_000_000; + + const rollups = buildReleaseRollups(songs, measurements); + const total = rollups.reduce((s, r) => s + r.streams, 0); + const perAlbum = rollups.map(r => (bandMid * r.streams) / total); + + expect(total).toBe(1000); + expect(perAlbum.reduce((s, v) => s + v, 0)).toBeCloseTo(bandMid, 5); + }); + + it("is null-safe: null album/name/artist entries never throw", () => { + const songs = [ + { album: null, isrc: "US1", artists: [null, { name: null }] }, + { album: " ", isrc: "US2", artists: null }, + ] as never; + + const rollups = buildReleaseRollups(songs, []); + + expect(rollups).toHaveLength(1); + expect(rollups[0].album).toBeNull(); + expect(rollups[0].trackCount).toBe(2); + expect(rollups[0].measuredTrackCount).toBe(0); + expect(rollups[0].artistNames).toEqual([]); + }); + + it("counts unmeasured tracks toward trackCount but not streams", () => { + const songs = [ + { album: "A", isrc: "US1", artists: [] }, + { album: "A", isrc: "US2", artists: [] }, + ]; + const rollups = buildReleaseRollups(songs, [{ isrc: "US1", playcount: 42 }]); + + expect(rollups[0]).toMatchObject({ trackCount: 2, measuredTrackCount: 1, streams: 42 }); + }); +}); diff --git a/lib/catalog/buildReleaseRollups.ts b/lib/catalog/buildReleaseRollups.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f311addbda9e0fc1e4b523ef3259cc8473ef31f GIT binary patch literal 2350 zcmb7F!EPHj5barCF-d`R*O4ga(iU=LHFbl+XzYXI9s)rSs1@a9!{rJj*N)&A=tuMm z^Cg)XlCo?WL35IpJA5*HhnY=nQ-7oNAN}+}Gp8Ya=&R z+u1O}+M0!O{~!80dGbiqwfb(RA~Wa6hR<|NXGB?Wvif+kTRNRKvESw8lW$Q6B^v~a zy&mE{xSdS?FSgc^B?AOB!#43&gajMdOb8m4-Vi@*LG_U5-Jqg zv{yfQHi9QZwXjxh;G&~;h@n@VMqC{SEzJhb2u=oJH64;>)is(m)HH!#{{0VDPWk%2 z12}%9l%M{IXy>F;Uy0+ z{-oNMJ}GVb*;-xT~X5$MmRDB<({LeJ43)dp#ccQDKDTEQhks7B>B zFfjEau8;$~%I!wFp2b(0K}ex^=QhIklljtWL~$Ri#6u+WiCu`t+a-7icd^k7?pX`0 zqo1;+dRTxQgw5y03opm;DngLL#3AblFJA1aj9~c&DKi z7-S;oF&ig>MV&be`AT|=7|)^#Z7b8^<}4@(Ig6Hf5VG!k9be~e0k~tQLgy?@g%H_f gw9gT&oC*546ou@=qJ;4=EJP-D05bna?eBVD0R^iN^8f$< literal 0 HcmV?d00001 From 44010130f834ab0e2747a2040411186072a1367d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 22 Jul 2026 19:08:41 -0500 Subject: [PATCH 4/9] feat(valuation-email): rich HTML (artist header, releases table w/ art + proportional value) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../renderValuationReportHtml.test.ts | 71 +++++++-- .../renderValuationReportHtml.ts | 144 +++++++++++++----- lib/spotify/getAlbums.ts | 1 + 3 files changed, 166 insertions(+), 50 deletions(-) diff --git a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts index 49534d90..bcde9497 100644 --- a/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts +++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts @@ -5,31 +5,79 @@ const baseParams = { catalogName: "Epitaph 10-Album Catalog", deepLinkUrl: "https://chat.recoupable.dev/catalogs/cat_1", albumCount: 10, + artist: { + name: "Nova", + imageUrl: "https://i.scdn.co/image/artist.jpg", + followers: 1_240_000, + }, valuation: { low: 7_200, mid: 10_400, high: 14_100 }, totalStreams: 3_480_000, measuredSongCount: 112, + releaseCount: 3, catalogAgeYears: 5, + releases: [ + { + album: "Album B", + artistNames: ["Nova"], + streams: 2_500_000, + value: 7_471, + artUrl: "https://i.scdn.co/image/albumB.jpg", + }, + { + album: "Album A", + artistNames: ["Nova", "Guest"], + streams: 980_000, + value: 2_929, + artUrl: null, + }, + ], }; describe("renderValuationReportHtml", () => { it("uses the fixed subject line", () => { - const { subject } = renderValuationReportHtml(baseParams); - expect(subject).toBe("Your catalog valuation is ready"); + expect(renderValuationReportHtml(baseParams).subject).toBe("Your catalog valuation is ready"); }); it("renders catalog name, headline value, range, streams, and counts", () => { const { html } = renderValuationReportHtml(baseParams); expect(html).toContain("Epitaph 10-Album Catalog"); + expect(html).toContain("Estimated catalog value"); expect(html).toContain("$10.4K"); - expect(html).toContain("$7.2K to $14.1K"); - expect(html).toContain("3.5M"); - expect(html).toContain("112"); - expect(html).toContain("10"); + expect(html).toContain("Range $7.2K to $14.1K"); + expect(html).toContain("3.5M"); // lifetime streams + expect(html).toContain("112"); // tracks measured + expect(html).toContain("Releases"); + expect(html).toContain("5 years"); // catalog age }); - it("links the CTA to the deep link", () => { + it("renders the artist header with image and follower count", () => { + const { html } = renderValuationReportHtml(baseParams); + expect(html).toContain('src="https://i.scdn.co/image/artist.jpg"'); + expect(html).toContain(">Nova<"); + expect(html).toContain("1.2M followers"); + }); + + it("renders a release table row with art, name, streams, and proportional value", () => { + const { html } = renderValuationReportHtml(baseParams); + expect(html).toContain('src="https://i.scdn.co/image/albumB.jpg"'); + expect(html).toContain("Album B"); + expect(html).toContain("Album A"); + expect(html).toContain("Nova, Guest"); // multi-artist row + expect(html).toContain("2.5M"); // release streams + expect(html).toContain("$7.5K"); // release value (7471 -> 7.5K) + expect(html).toContain("$2.9K"); // release value (2929 -> 2.9K) + }); + + it("shows the directional-model disclaimer when valued", () => { + const { html } = renderValuationReportHtml(baseParams); + expect(html).toContain("Directional model, not an appraisal"); + expect(html).toContain("10 to 16x"); + }); + + it("links the CTA to the deep link with the full-report copy", () => { const { html } = renderValuationReportHtml(baseParams); expect(html).toContain('href="https://chat.recoupable.dev/catalogs/cat_1"'); + expect(html).toContain("Get the full report with Recoup"); }); it("escapes HTML in the catalog name", () => { @@ -37,21 +85,20 @@ describe("renderValuationReportHtml", () => { ...baseParams, catalogName: 'Catalog', }); - expect(html).not.toContain(''); + expect(html).not.toContain('Catalog'); expect(html).toContain("<img src="x">Catalog"); }); - it("omits the valuation and streams rows when no measurements are available", () => { + it("degrades to a link-only email when no measurements are available", () => { const { html } = renderValuationReportHtml({ catalogName: null, deepLinkUrl: "https://chat.recoupable.dev", albumCount: 4, }); - expect(html).not.toContain("Estimated value"); - expect(html).not.toContain("Lifetime Spotify streams"); + expect(html).not.toContain("Estimated catalog value"); + expect(html).not.toContain("Directional model"); expect(html).toContain("Your catalog"); expect(html).toContain('href="https://chat.recoupable.dev"'); - expect(html).toContain("4"); }); it("contains no em or en dashes in outward-facing copy", () => { diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts index c79bc7d7..eb2d9fee 100644 --- a/lib/emails/valuationReport/renderValuationReportHtml.ts +++ b/lib/emails/valuationReport/renderValuationReportHtml.ts @@ -2,80 +2,148 @@ import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; import { escapeHtml } from "@/lib/emails/escapeHtml"; import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; +export type ValuationReleaseRow = { + album: string | null; + artistNames: string[]; + streams: number; + /** Proportional share of the band's central value (streams / totalStreams x mid). */ + value: number; + artUrl: string | null; +}; + export type ValuationReportEmailParams = { catalogName: string | null; deepLinkUrl: string; albumCount: number; + artist?: { name: string | null; imageUrl: string | null; followers: number | null }; valuation?: { low: number; mid: number; high: number }; totalStreams?: number; measuredSongCount?: number; + releaseCount?: number; catalogAgeYears?: number; + releases?: ValuationReleaseRow[]; }; const SUBJECT = "Your catalog valuation is ready"; +const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; -/** - * Deterministic house-style renderer for the valuation-report email - * (recoupable/chat#1867): the summary a signup can return to after closing - * the tab. Styling follows DESIGN.md, mirroring the scrape-digest sibling — - * achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders, #6b6b6b muted), - * tables + inline styles only, system font stack, fixed CHAT_APP_URL-based - * deep link (never a derived deployment URL). Copy avoids em/en dashes and - * uses "to" for the valuation range. - */ -export function renderValuationReportHtml(params: ValuationReportEmailParams): { - subject: string; - html: string; -} { - const name = params.catalogName ? escapeHtml(params.catalogName) : "Your catalog"; +const usd = (n: number) => `$${formatCompactNumber(n)}`; - const valuationBlock = params.valuation - ? `
-

Estimated value

-

$${formatCompactNumber(params.valuation.mid)}

-

Range: $${formatCompactNumber(params.valuation.low)} to $${formatCompactNumber(params.valuation.high)}

-
` +function renderArtistHeader(artist: ValuationReportEmailParams["artist"]): string { + if (!artist || !artist.name) return ""; + const name = escapeHtml(artist.name); + const followers = + artist.followers != null + ? `

${formatCompactNumber(artist.followers)} followers

` + : ""; + const avatar = artist.imageUrl + ? `${name}` : ""; + return ` +${avatar} + +

${name}

${followers}
`; +} - const statRows = [ +function renderValuationBlock(valuation: ValuationReportEmailParams["valuation"]): string { + if (!valuation) return ""; + return `
+

Estimated catalog value

+

${usd(valuation.mid)}

+

Range ${usd(valuation.low)} to ${usd(valuation.high)}

+
`; +} + +function renderStatRow(params: ValuationReportEmailParams): string { + const stats = [ params.totalStreams != null - ? ["Lifetime Spotify streams", formatCompactNumber(params.totalStreams)] + ? ["Lifetime streams", formatCompactNumber(params.totalStreams)] : null, params.measuredSongCount != null - ? ["Songs measured", formatCompactNumber(params.measuredSongCount)] + ? ["Tracks measured", formatCompactNumber(params.measuredSongCount)] : null, - ["Albums measured", formatCompactNumber(params.albumCount)], + ["Releases", formatCompactNumber(params.releaseCount ?? params.albumCount)], params.catalogAgeYears != null ? ["Catalog age", `${params.catalogAgeYears} year${params.catalogAgeYears === 1 ? "" : "s"}`] : null, - ] - .filter((row): row is [string, string] => row !== null) + ].filter((s): s is [string, string] => s !== null); + + const cells = stats .map( ([label, value]) => - `${label}${value}`, + `

${value}

${label}

`, ) .join(""); + return `${cells}
`; +} + +function renderReleaseRow(release: ValuationReleaseRow): string { + const album = release.album ? escapeHtml(release.album) : "Untitled release"; + const artists = release.artistNames.length + ? `

${escapeHtml(release.artistNames.join(", "))}

` + : ""; + const art = release.artUrl + ? `` + : `
`; + return ` +${art} +

${album}

${artists} +${formatCompactNumber(release.streams)} +${usd(release.value)} +`; +} + +function renderReleasesTable(releases: ValuationReleaseRow[] | undefined): string { + if (!releases || releases.length === 0) return ""; + const header = ` +Release +Streams +Value +`; + const rows = releases.map(renderReleaseRow).join(""); + return `${header}${rows}
`; +} + +/** + * Deterministic house-style renderer for the valuation-report email + * (recoupable/chat#1867, enriched per chat#1881): reproduces the marketing / + * chat catalog-report result so the email reinforces the same numbers a signup + * already saw — artist header, estimated value band, measured-scope stats, and + * a per-release table with album art + proportional-share value. Styling + * follows DESIGN.md: achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 borders, + * #6b6b6b muted), tables + inline styles only, system font stack, fixed + * CHAT_APP_URL-based deep link (never a derived deployment URL). Copy avoids + * em/en dashes and uses "to" for ranges. Per-album value is a proportional + * share of the single headline band (value = mid x streams/total), so the rows + * sum to the headline and never diverge from the funnel. + */ +export function renderValuationReportHtml(params: ValuationReportEmailParams): { + subject: string; + html: string; +} { + const name = params.catalogName ? escapeHtml(params.catalogName) : "Your catalog"; - const methodNote = params.valuation - ? `

Estimate derived from lifetime Spotify play counts using a 10 to 16x net-royalty multiple. Not a formal appraisal.

` + const disclaimer = params.valuation + ? `

Directional model, not an appraisal. Based on live Spotify play counts measured today, an annual run-rate from your catalog's lifetime average, and a master-side net royalty share times a 10 to 16x market multiple. Real statements collapse the range.

` : ""; const html = `` + : ""; + return `
-
- +
+

Catalog valuation

-

${name}

-

Your measurement run is complete.

+

${name}

Recoup
-${valuationBlock} -${statRows}
-
View your full report →
-${methodNote} -

You're receiving this because you ran a catalog valuation on Recoup.

+${renderArtistHeader(params.artist)} +${renderValuationBlock(params.valuation)} +${renderStatRow(params)} +${renderReleasesTable(params.releases)} +${disclaimer} +
Get the full report with Recoup →
+

You're receiving this because you ran a catalog valuation on Recoup.

`; diff --git a/lib/spotify/getAlbums.ts b/lib/spotify/getAlbums.ts index 752b2b45..b181a2b0 100644 --- a/lib/spotify/getAlbums.ts +++ b/lib/spotify/getAlbums.ts @@ -2,6 +2,7 @@ export type SpotifyAlbum = { id: string; name?: string; release_date?: string; + images?: Array<{ url: string; height?: number | null; width?: number | null }>; }; // Spotify's GET /v1/albums caps ids at 20 per request. From 46ec76ee0ae44a74ebcf00e2969f56846c9049b8 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 22 Jul 2026 19:11:30 -0500 Subject: [PATCH 5/9] feat(valuation-email): gather per-release table + artist header in sendValuationReportEmail Co-Authored-By: Claude Opus 4.8 (1M context) --- .../buildValuationReleaseRows.test.ts | 64 ++++++++++++++ .../sendValuationReportEmail.test.ts | 78 +++++++++++++++-- .../buildValuationReleaseRows.ts | 41 +++++++++ .../sendValuationReportEmail.ts | 87 +++++++++++++++++-- 4 files changed, 254 insertions(+), 16 deletions(-) create mode 100644 lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts create mode 100644 lib/emails/valuationReport/buildValuationReleaseRows.ts diff --git a/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts new file mode 100644 index 00000000..0ee7639e --- /dev/null +++ b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "vitest"; +import { + buildAlbumArtMap, + buildValuationReleaseRows, +} from "../buildValuationReleaseRows"; + +describe("buildAlbumArtMap", () => { + it("maps lowercased album names to the smallest cover-art url", () => { + const albums = [ + { + id: "1", + name: "Album A", + images: [ + { url: "big.jpg", height: 640, width: 640 }, + { url: "small.jpg", height: 64, width: 64 }, + ], + }, + { id: "2", name: "No Art" }, + ]; + const map = buildAlbumArtMap(albums); + expect(map.get("album a")).toBe("small.jpg"); + expect(map.has("no art")).toBe(false); + }); +}); + +describe("buildValuationReleaseRows", () => { + const rollups = [ + { album: "Album B", artistNames: ["Nova"], trackCount: 1, measuredTrackCount: 1, streams: 700 }, + { album: "Album A", artistNames: ["Nova"], trackCount: 1, measuredTrackCount: 1, streams: 300 }, + ]; + + it("computes proportional value that sums to the headline band", () => { + const rows = buildValuationReleaseRows({ + rollups, + totalStreams: 1000, + bandMid: 1_000_000, + artByAlbum: new Map([["album b", "b.jpg"]]), + }); + expect(rows.map(r => r.value)).toEqual([700_000, 300_000]); + expect(rows.reduce((s, r) => s + r.value, 0)).toBe(1_000_000); + expect(rows[0].artUrl).toBe("b.jpg"); + expect(rows[1].artUrl).toBeNull(); + }); + + it("is safe when totalStreams is zero (no divide-by-zero)", () => { + const rows = buildValuationReleaseRows({ + rollups, + totalStreams: 0, + bandMid: 1_000_000, + artByAlbum: new Map(), + }); + expect(rows.every(r => r.value === 0)).toBe(true); + }); + + it("leaves art null for untitled releases", () => { + const rows = buildValuationReleaseRows({ + rollups: [{ album: null, artistNames: [], trackCount: 1, measuredTrackCount: 0, streams: 0 }], + totalStreams: 1000, + bandMid: 1000, + artByAlbum: new Map([["", "x.jpg"]]), + }); + expect(rows[0].artUrl).toBeNull(); + }); +}); diff --git a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts index a93043de..7e851cfc 100644 --- a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts +++ b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts @@ -7,8 +7,13 @@ import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSen import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; +import { selectCatalogSongsWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists"; import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbums from "@/lib/spotify/getAlbums"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import type { SpotifyArtist } from "@/types/spotify.types"; import type { Tables } from "@/types/database.types"; vi.mock("@/lib/emails/sendEmail", () => ({ sendEmailWithResend: vi.fn() })); @@ -21,18 +26,33 @@ vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate", () => ({ selectCatalogMeasurementsAggregate: vi.fn(), })); +vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsPage", () => ({ + selectCatalogMeasurementsPage: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists", () => ({ + selectCatalogSongsWithArtists: vi.fn(), +})); vi.mock("@/lib/catalog/getCatalogEarliestReleaseDate", () => ({ getCatalogEarliestReleaseDate: vi.fn(), })); +vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); +vi.mock("@/lib/spotify/getAlbums", () => ({ default: vi.fn() })); const snapshot = { id: "snap_1", account: "acc_1", catalog: "cat_1", album_count: 10, + album_ids: ["al_1"], state: "done", } as Tables<"playcount_snapshots">; +const artist = { + name: "Epitaph", + images: [{ url: "https://art/artist.jpg", height: 640, width: 640 }], + followers: { total: 50_000 }, +} as SpotifyArtist; + function arrange() { vi.mocked(selectEmailSendLog).mockResolvedValue([]); vi.mocked(selectAccountEmails).mockResolvedValue([ @@ -47,6 +67,34 @@ function arrange() { totalStreams: 3_480_000, }); vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-01-01"); + vi.mocked(selectCatalogSongsWithArtists).mockResolvedValue({ + songs: [ + { isrc: "US1", album: "Epitaph Hits", artists: [{ name: "Epitaph" }] }, + ] as never, + total_count: 1, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([ + { isrc: "US1", title: "Track", playcount: 3_480_000, measured_at: "2026-07-22" }, + ]); + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: "tok", + token_type: "Bearer", + expires_in: 3600, + error: null, + }); + vi.mocked(getAlbums).mockResolvedValue({ + albums: [ + { + id: "al_1", + name: "Epitaph Hits", + images: [ + { url: "https://art/big.jpg", height: 640, width: 640 }, + { url: "https://art/small.jpg", height: 64, width: 64 }, + ], + }, + ], + error: null, + }); vi.mocked(sendEmailWithResend).mockResolvedValue({ id: "resend_1" }); } @@ -56,8 +104,8 @@ describe("sendValuationReportEmail", () => { arrange(); }); - it("sends the report with a per-snapshot idempotency key and logs the send", async () => { - const result = await sendValuationReportEmail(snapshot); + it("sends the rich report (artist header + release table) and logs the send", async () => { + const result = await sendValuationReportEmail(snapshot, { artist }); expect(sendEmailWithResend).toHaveBeenCalledTimes(1); const [payload, options] = vi.mocked(sendEmailWithResend).mock.calls[0]; @@ -66,9 +114,14 @@ describe("sendValuationReportEmail", () => { expect(payload.subject).toBe("Your catalog valuation is ready"); expect(payload.html).toContain("Epitaph Catalog"); expect(payload.html).toContain("https://chat.recoupable.dev/catalogs/cat_1"); + // artist header + expect(payload.html).toContain("https://art/artist.jpg"); + expect(payload.html).toContain("50K followers"); + // release table with album art + expect(payload.html).toContain("Epitaph Hits"); + expect(payload.html).toContain("https://art/small.jpg"); expect(options).toEqual({ idempotencyKey: "valuation-report/snap_1" }); - expect(logEmailAttempt).toHaveBeenCalledTimes(1); const attempt = vi.mocked(logEmailAttempt).mock.calls[0][0]; expect(attempt.status).toBe("sent"); expect(attempt.accountId).toBe("acc_1"); @@ -82,7 +135,7 @@ describe("sendValuationReportEmail", () => { it("skips without sending when a send is already logged for this snapshot", async () => { vi.mocked(selectEmailSendLog).mockResolvedValue([{ id: "log_1" } as Tables<"email_send_log">]); - const result = await sendValuationReportEmail(snapshot); + const result = await sendValuationReportEmail(snapshot, { artist }); expect(sendEmailWithResend).not.toHaveBeenCalled(); expect(logEmailAttempt).not.toHaveBeenCalled(); @@ -92,13 +145,13 @@ describe("sendValuationReportEmail", () => { it("skips when the account has no email address", async () => { vi.mocked(selectAccountEmails).mockResolvedValue([]); - const result = await sendValuationReportEmail(snapshot); + const result = await sendValuationReportEmail(snapshot, { artist }); expect(sendEmailWithResend).not.toHaveBeenCalled(); expect(result).toEqual({ sent: false, skipped: "no_email" }); }); - it("falls back to the app link and album count when no catalog was claimed", async () => { + it("falls back to the app link when no catalog was claimed", async () => { const result = await sendValuationReportEmail({ ...snapshot, catalog: null }); expect(selectCatalogById).not.toHaveBeenCalled(); @@ -109,12 +162,23 @@ describe("sendValuationReportEmail", () => { expect(result).toEqual({ sent: true, resendId: "resend_1" }); }); + it("still sends the headline email when the release-table build fails", async () => { + vi.mocked(selectCatalogSongsWithArtists).mockRejectedValue(new Error("db down")); + + const result = await sendValuationReportEmail(snapshot, { artist }); + + const [payload] = vi.mocked(sendEmailWithResend).mock.calls[0]; + expect(payload.html).toContain("Epitaph Catalog"); + expect(payload.html).toContain("$"); // headline band still rendered + expect(result).toEqual({ sent: true, resendId: "resend_1" }); + }); + it("logs send_failed and reports the failure when Resend rejects the send", async () => { vi.mocked(sendEmailWithResend).mockResolvedValue( NextResponse.json({ error: "Failed to send email" }, { status: 502 }), ); - const result = await sendValuationReportEmail(snapshot); + const result = await sendValuationReportEmail(snapshot, { artist }); const attempt = vi.mocked(logEmailAttempt).mock.calls[0][0]; expect(attempt.status).toBe("send_failed"); diff --git a/lib/emails/valuationReport/buildValuationReleaseRows.ts b/lib/emails/valuationReport/buildValuationReleaseRows.ts new file mode 100644 index 00000000..9ed5d4da --- /dev/null +++ b/lib/emails/valuationReport/buildValuationReleaseRows.ts @@ -0,0 +1,41 @@ +import type { ReleaseRollup } from "@/lib/catalog/buildReleaseRollups"; +import type { SpotifyAlbum } from "@/lib/spotify/getAlbums"; +import type { ValuationReleaseRow } from "./renderValuationReportHtml"; + +/** + * Map each album name (trimmed, lowercased) to its smallest cover-art URL from + * a Spotify /v1/albums response. Spotify returns images largest-first, so the + * last entry is the ~64px thumbnail best suited to an email row. + */ +export function buildAlbumArtMap(albums: SpotifyAlbum[]): Map { + const map = new Map(); + for (const album of albums ?? []) { + const name = album?.name?.trim().toLowerCase(); + const images = album?.images ?? []; + const url = images[images.length - 1]?.url; + if (name && url && !map.has(name)) map.set(name, url); + } + return map; +} + +/** + * Turn per-release rollups into the email's release rows: proportional-share + * value (mid x streams / totalStreams, so the rows sum to the headline band and + * never diverge from the funnel) joined with album art by name. Rollups arrive + * pre-sorted by streams descending (buildReleaseRollups). + */ +export function buildValuationReleaseRows(params: { + rollups: ReleaseRollup[]; + totalStreams: number; + bandMid: number; + artByAlbum: Map; +}): ValuationReleaseRow[] { + const { rollups, totalStreams, bandMid, artByAlbum } = params; + return rollups.map(rollup => ({ + album: rollup.album, + artistNames: rollup.artistNames, + streams: rollup.streams, + value: totalStreams > 0 ? (bandMid * rollup.streams) / totalStreams : 0, + artUrl: rollup.album ? (artByAlbum.get(rollup.album.trim().toLowerCase()) ?? null) : null, + })); +} diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts index 62b60922..4b3db911 100644 --- a/lib/emails/valuationReport/sendValuationReportEmail.ts +++ b/lib/emails/valuationReport/sendValuationReportEmail.ts @@ -3,13 +3,23 @@ import { CHAT_APP_URL, RECOUP_FROM_EMAIL } from "@/lib/const"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; import { renderValuationReportHtml } from "@/lib/emails/valuationReport/renderValuationReportHtml"; +import { + buildAlbumArtMap, + buildValuationReleaseRows, +} from "@/lib/emails/valuationReport/buildValuationReleaseRows"; import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; +import { selectCatalogSongsWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists"; import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; +import { buildReleaseRollups } from "@/lib/catalog/buildReleaseRollups"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbums from "@/lib/spotify/getAlbums"; import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/renderValuationReportHtml"; +import type { SpotifyArtist } from "@/types/spotify.types"; import type { Tables } from "@/types/database.types"; export type SendValuationReportEmailResult = @@ -17,21 +27,63 @@ export type SendValuationReportEmailResult = | { sent: false; skipped: "already_sent" | "no_email" } | { sent: false; error: string }; +// One page is enough for the report: valuation-claimed catalogs are well under +// this, and the release table only needs the measured tracks. +const MEASUREMENTS_LIMIT = 1000; + +/** + * Gather the per-release table (album, streams, proportional value, art) for a + * valued catalog. Best-effort: any read/Spotify failure returns [] so the email + * still sends with the headline band, just without the breakdown. + */ +async function buildReleaseRows( + catalogId: string, + albumIds: string[], + totalStreams: number, + bandMid: number, +): Promise { + try { + const [{ songs }, measurements, tokenResult] = await Promise.all([ + selectCatalogSongsWithArtists({ catalogId }), + selectCatalogMeasurementsPage({ catalogId, page: 1, limit: MEASUREMENTS_LIMIT }), + generateAccessToken(), + ]); + + const rollups = buildReleaseRollups(songs, measurements ?? []); + + let artByAlbum = new Map(); + if (albumIds.length > 0 && tokenResult.access_token) { + const { albums } = await getAlbums({ ids: albumIds, accessToken: tokenResult.access_token }); + if (albums) artByAlbum = buildAlbumArtMap(albums); + } + + return buildValuationReleaseRows({ rollups, totalStreams, bandMid, artByAlbum }); + } catch (error) { + console.error(`Valuation email release-table build failed for catalog ${catalogId}:`, error); + return []; + } +} + /** * Emails the valuation summary for a completed snapshot run to the owning - * account (recoupable/chat#1867): headline valuation band + lifetime streams - * + measured counts, deep-linked to the catalog report on chat. Idempotent - * per run twice over: a `"snapshot_id"` marker in `email_send_log.raw_body` - * guards re-invocations for as long as the log exists, and the Resend - * idempotency key (`valuation-report/`) guards racing retries within - * Resend's 24h window. Skips silently when the account has no email. - * Measurement enrichment is best-effort: a read failure degrades to the - * link-only email rather than dropping the send. + * account (recoupable/chat#1867, enriched per chat#1881). Reproduces the + * marketing / chat catalog-report result — artist header, headline band, + * measured-scope stats, and a per-release table with album art + proportional + * value — so it reinforces numbers the signup already saw. Idempotent per run + * twice over: a `"snapshot_id"` marker in `email_send_log.raw_body` guards + * re-invocations for as long as the log exists, and the Resend idempotency key + * (`valuation-report/`) guards racing retries within Resend's 24h window. + * Skips silently when the account has no email. Every enrichment step is + * best-effort: a failure degrades toward the link-only email rather than + * dropping the send. Fired from runValuationHandler after the catalog is + * materialized (chat#1881), so `snapshot.catalog` is set by call time. * - * @param snapshot - The completed playcount_snapshots row + * @param snapshot - The completed playcount_snapshots row (with catalog claimed) + * @param options.artist - The searched Spotify artist for the header (name, avatar, followers) */ export async function sendValuationReportEmail( snapshot: Tables<"playcount_snapshots">, + options: { artist?: SpotifyArtist | null } = {}, ): Promise { // Long-window idempotency: a prior successful send for this run is marked by // the `"snapshot_id":""` marker in raw_body (Resend's key only covers 24h). @@ -56,6 +108,14 @@ export async function sendValuationReportEmail( albumCount: snapshot.album_count ?? snapshot.album_ids?.length ?? 0, }; + if (options.artist?.name) { + params.artist = { + name: options.artist.name, + imageUrl: options.artist.images?.[0]?.url ?? null, + followers: options.artist.followers?.total ?? null, + }; + } + if (snapshot.catalog) { try { const [catalog, aggregate, earliestReleaseDate] = await Promise.all([ @@ -73,6 +133,15 @@ export async function sendValuationReportEmail( params.totalStreams = aggregate.totalStreams; params.measuredSongCount = aggregate.measuredSongCount; params.catalogAgeYears = catalogAgeYears; + + const releases = await buildReleaseRows( + snapshot.catalog, + snapshot.album_ids ?? [], + aggregate.totalStreams, + valuation.mid, + ); + params.releases = releases; + params.releaseCount = releases?.length || undefined; } } catch (error) { console.error(`Valuation email enrichment failed for snapshot ${snapshot.id}:`, error); From 9bf12b6abf6292114ed8373e93755bad96fcd1b3 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 22 Jul 2026 19:14:05 -0500 Subject: [PATCH 6/9] feat(valuation-email): fire after catalog is materialized (fix empty email) Move the send out of playcountSnapshotWorkflow (where snapshot.catalog is still null) into runValuationHandler via next/server after(), pointed at the freshly-materialized catalog. Deletes sendValuationEmailStep. Fixes the nearly-empty email root cause (chat#1881). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/sendValuationEmailStep.test.ts | 32 ------------------- app/workflows/playcountSnapshotWorkflow.ts | 2 -- app/workflows/sendValuationEmailStep.ts | 23 ------------- lib/valuation/runValuationHandler.ts | 14 +++++++- 4 files changed, 13 insertions(+), 58 deletions(-) delete mode 100644 app/workflows/__tests__/sendValuationEmailStep.test.ts delete mode 100644 app/workflows/sendValuationEmailStep.ts diff --git a/app/workflows/__tests__/sendValuationEmailStep.test.ts b/app/workflows/__tests__/sendValuationEmailStep.test.ts deleted file mode 100644 index c35d8c09..00000000 --- a/app/workflows/__tests__/sendValuationEmailStep.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { sendValuationEmailStep } from "../sendValuationEmailStep"; -import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; -import type { Tables } from "@/types/database.types"; - -vi.mock("@/lib/emails/valuationReport/sendValuationReportEmail", () => ({ - sendValuationReportEmail: vi.fn(), -})); - -const snapshot = { id: "snap_1", account: "acc_1" } as Tables<"playcount_snapshots">; - -describe("sendValuationEmailStep", () => { - beforeEach(() => vi.clearAllMocks()); - - it("delegates to sendValuationReportEmail", async () => { - vi.mocked(sendValuationReportEmail).mockResolvedValue({ sent: true, resendId: "resend_1" }); - - await sendValuationEmailStep(snapshot); - - expect(sendValuationReportEmail).toHaveBeenCalledWith(snapshot); - }); - - it("never throws: an email failure must not fail a completed snapshot", async () => { - const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); - vi.mocked(sendValuationReportEmail).mockRejectedValue(new Error("resend down")); - - await expect(sendValuationEmailStep(snapshot)).resolves.toBeUndefined(); - - expect(consoleError).toHaveBeenCalled(); - consoleError.mockRestore(); - }); -}); diff --git a/app/workflows/playcountSnapshotWorkflow.ts b/app/workflows/playcountSnapshotWorkflow.ts index 09a30b56..8b6c38bc 100644 --- a/app/workflows/playcountSnapshotWorkflow.ts +++ b/app/workflows/playcountSnapshotWorkflow.ts @@ -1,6 +1,5 @@ import { getSnapshotStep } from "@/app/workflows/getSnapshotStep"; import { markSnapshotStep } from "@/app/workflows/markSnapshotStep"; -import { sendValuationEmailStep } from "@/app/workflows/sendValuationEmailStep"; import { fetchChunkStep } from "@/app/workflows/fetchChunkStep"; import { mapAndWriteChunkStep } from "@/app/workflows/mapAndWriteChunkStep"; @@ -30,7 +29,6 @@ export async function playcountSnapshotWorkflow(snapshotId: string) { } await markSnapshotStep(snapshotId, { state: "done" }); - await sendValuationEmailStep(snapshot); return { success: true as const, measurementsWritten: written }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/app/workflows/sendValuationEmailStep.ts b/app/workflows/sendValuationEmailStep.ts deleted file mode 100644 index 7d8aacab..00000000 --- a/app/workflows/sendValuationEmailStep.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; -import type { Tables } from "@/types/database.types"; - -/** - * Best-effort valuation-report email after a snapshot run reaches `done` - * (recoupable/chat#1867). Runs as its own step so the send is memoized across - * workflow retries, and never throws: by this point the measurements are - * written and the run is marked done, so an email failure must not flip a - * successful run to failed. - * - * @param snapshot - The completed snapshot row (loaded by getSnapshotStep) - */ -export async function sendValuationEmailStep( - snapshot: Tables<"playcount_snapshots">, -): Promise { - "use step"; - try { - const result = await sendValuationReportEmail(snapshot); - console.log(`[playcount-snapshot] Valuation email for ${snapshot.id}:`, result); - } catch (error) { - console.error(`[playcount-snapshot] Valuation email failed for ${snapshot.id}:`, error); - } -} diff --git a/lib/valuation/runValuationHandler.ts b/lib/valuation/runValuationHandler.ts index 1e74d7d3..792f16dd 100644 --- a/lib/valuation/runValuationHandler.ts +++ b/lib/valuation/runValuationHandler.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextRequest, NextResponse, after } from "next/server"; import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import generateAccessToken from "@/lib/spotify/generateAccessToken"; @@ -10,6 +10,7 @@ import { createSnapshotCatalog } from "@/lib/catalog/createSnapshotCatalog"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; +import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; import { validateRunValuationRequest } from "./validateRunValuationRequest"; import { extractValuationAlbums } from "./extractValuationAlbums"; import { waitForSnapshotMeasurements } from "./waitForSnapshotMeasurements"; @@ -140,6 +141,17 @@ export async function runValuationHandler(request: NextRequest): Promise + sendValuationReportEmail( + { ...snapshot, catalog: catalog.id }, + { artist: searchedArtist }, + ).catch(error => console.error("Valuation report email failed:", error)), + ); + return successResponse({ catalog, band: valuation, From 57d88e6f21b7448fe1eb1c82b78358fd3eb655a6 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 06:01:48 -0500 Subject: [PATCH 7/9] style(emails): prettier fix valuation-report test files (unblock CI) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildValuationReleaseRows.test.ts | 5 +---- .../__tests__/sendValuationReportEmail.test.ts | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts index 0ee7639e..fecac190 100644 --- a/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts +++ b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import { - buildAlbumArtMap, - buildValuationReleaseRows, -} from "../buildValuationReleaseRows"; +import { buildAlbumArtMap, buildValuationReleaseRows } from "../buildValuationReleaseRows"; describe("buildAlbumArtMap", () => { it("maps lowercased album names to the smallest cover-art url", () => { diff --git a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts index 7e851cfc..22ae78c3 100644 --- a/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts +++ b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts @@ -68,9 +68,7 @@ function arrange() { }); vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-01-01"); vi.mocked(selectCatalogSongsWithArtists).mockResolvedValue({ - songs: [ - { isrc: "US1", album: "Epitaph Hits", artists: [{ name: "Epitaph" }] }, - ] as never, + songs: [{ isrc: "US1", album: "Epitaph Hits", artists: [{ name: "Epitaph" }] }] as never, total_count: 1, }); vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([ From 405f4321bacd9854214ded551c860857d003cb8d Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 06:33:39 -0500 Subject: [PATCH 8/9] refactor(emails): one-function-per-file for valuation-report helpers (SRP) Addresses PR review: split each render/build helper into its own lib file. - renderArtistHeader / renderValuationBlock / renderStatRow / renderReleaseRow / renderReleasesTable out of renderValuationReportHtml.ts (151->56 LOC) - buildAlbumArtMap out of buildValuationReleaseRows.ts - buildReleaseRows out of sendValuationReportEmail.ts (179->134 LOC) - shared usd() -> formatUsd.ts; row/param types -> valuationReportTypes.ts (breaks the render<->types cycle) - buildAlbumArtMap test moved to its own file No behavior change; 257 domain tests green, tsc + lint + format clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/buildAlbumArtMap.test.ts | 21 ++++ .../buildValuationReleaseRows.test.ts | 21 +--- .../valuationReport/buildAlbumArtMap.ts | 17 +++ .../valuationReport/buildReleaseRows.ts | 45 ++++++++ .../buildValuationReleaseRows.ts | 19 +--- lib/emails/valuationReport/formatUsd.ts | 10 ++ .../valuationReport/renderArtistHeader.ts | 23 ++++ .../valuationReport/renderReleaseRow.ts | 25 +++++ .../valuationReport/renderReleasesTable.ts | 17 +++ lib/emails/valuationReport/renderStatRow.ts | 29 +++++ .../valuationReport/renderValuationBlock.ts | 15 +++ .../renderValuationReportHtml.ts | 105 +----------------- .../sendValuationReportEmail.ts | 49 +------- .../valuationReport/valuationReportTypes.ts | 27 +++++ 14 files changed, 238 insertions(+), 185 deletions(-) create mode 100644 lib/emails/valuationReport/__tests__/buildAlbumArtMap.test.ts create mode 100644 lib/emails/valuationReport/buildAlbumArtMap.ts create mode 100644 lib/emails/valuationReport/buildReleaseRows.ts create mode 100644 lib/emails/valuationReport/formatUsd.ts create mode 100644 lib/emails/valuationReport/renderArtistHeader.ts create mode 100644 lib/emails/valuationReport/renderReleaseRow.ts create mode 100644 lib/emails/valuationReport/renderReleasesTable.ts create mode 100644 lib/emails/valuationReport/renderStatRow.ts create mode 100644 lib/emails/valuationReport/renderValuationBlock.ts create mode 100644 lib/emails/valuationReport/valuationReportTypes.ts diff --git a/lib/emails/valuationReport/__tests__/buildAlbumArtMap.test.ts b/lib/emails/valuationReport/__tests__/buildAlbumArtMap.test.ts new file mode 100644 index 00000000..3e154342 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/buildAlbumArtMap.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { buildAlbumArtMap } from "../buildAlbumArtMap"; + +describe("buildAlbumArtMap", () => { + it("maps lowercased album names to the smallest cover-art url", () => { + const albums = [ + { + id: "1", + name: "Album A", + images: [ + { url: "big.jpg", height: 640, width: 640 }, + { url: "small.jpg", height: 64, width: 64 }, + ], + }, + { id: "2", name: "No Art" }, + ]; + const map = buildAlbumArtMap(albums); + expect(map.get("album a")).toBe("small.jpg"); + expect(map.has("no art")).toBe(false); + }); +}); diff --git a/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts index fecac190..9db0e93c 100644 --- a/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts +++ b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts @@ -1,24 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildAlbumArtMap, buildValuationReleaseRows } from "../buildValuationReleaseRows"; - -describe("buildAlbumArtMap", () => { - it("maps lowercased album names to the smallest cover-art url", () => { - const albums = [ - { - id: "1", - name: "Album A", - images: [ - { url: "big.jpg", height: 640, width: 640 }, - { url: "small.jpg", height: 64, width: 64 }, - ], - }, - { id: "2", name: "No Art" }, - ]; - const map = buildAlbumArtMap(albums); - expect(map.get("album a")).toBe("small.jpg"); - expect(map.has("no art")).toBe(false); - }); -}); +import { buildValuationReleaseRows } from "../buildValuationReleaseRows"; describe("buildValuationReleaseRows", () => { const rollups = [ diff --git a/lib/emails/valuationReport/buildAlbumArtMap.ts b/lib/emails/valuationReport/buildAlbumArtMap.ts new file mode 100644 index 00000000..a4fac537 --- /dev/null +++ b/lib/emails/valuationReport/buildAlbumArtMap.ts @@ -0,0 +1,17 @@ +import type { SpotifyAlbum } from "@/lib/spotify/getAlbums"; + +/** + * Map each album name (trimmed, lowercased) to its smallest cover-art URL from + * a Spotify /v1/albums response. Spotify returns images largest-first, so the + * last entry is the ~64px thumbnail best suited to an email row. + */ +export function buildAlbumArtMap(albums: SpotifyAlbum[]): Map { + const map = new Map(); + for (const album of albums ?? []) { + const name = album?.name?.trim().toLowerCase(); + const images = album?.images ?? []; + const url = images[images.length - 1]?.url; + if (name && url && !map.has(name)) map.set(name, url); + } + return map; +} diff --git a/lib/emails/valuationReport/buildReleaseRows.ts b/lib/emails/valuationReport/buildReleaseRows.ts new file mode 100644 index 00000000..d01fc075 --- /dev/null +++ b/lib/emails/valuationReport/buildReleaseRows.ts @@ -0,0 +1,45 @@ +import { buildAlbumArtMap } from "@/lib/emails/valuationReport/buildAlbumArtMap"; +import { buildValuationReleaseRows } from "@/lib/emails/valuationReport/buildValuationReleaseRows"; +import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; +import { selectCatalogSongsWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists"; +import { buildReleaseRollups } from "@/lib/catalog/buildReleaseRollups"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbums from "@/lib/spotify/getAlbums"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; + +// One page is enough for the report: valuation-claimed catalogs are well under +// this, and the release table only needs the measured tracks. +const MEASUREMENTS_LIMIT = 1000; + +/** + * Gather the per-release table (album, streams, proportional value, art) for a + * valued catalog. Best-effort: any read/Spotify failure returns [] so the email + * still sends with the headline band, just without the breakdown. + */ +export async function buildReleaseRows( + catalogId: string, + albumIds: string[], + totalStreams: number, + bandMid: number, +): Promise { + try { + const [{ songs }, measurements, tokenResult] = await Promise.all([ + selectCatalogSongsWithArtists({ catalogId }), + selectCatalogMeasurementsPage({ catalogId, page: 1, limit: MEASUREMENTS_LIMIT }), + generateAccessToken(), + ]); + + const rollups = buildReleaseRollups(songs, measurements ?? []); + + let artByAlbum = new Map(); + if (albumIds.length > 0 && tokenResult.access_token) { + const { albums } = await getAlbums({ ids: albumIds, accessToken: tokenResult.access_token }); + if (albums) artByAlbum = buildAlbumArtMap(albums); + } + + return buildValuationReleaseRows({ rollups, totalStreams, bandMid, artByAlbum }); + } catch (error) { + console.error(`Valuation email release-table build failed for catalog ${catalogId}:`, error); + return []; + } +} diff --git a/lib/emails/valuationReport/buildValuationReleaseRows.ts b/lib/emails/valuationReport/buildValuationReleaseRows.ts index 9ed5d4da..2f29a9c6 100644 --- a/lib/emails/valuationReport/buildValuationReleaseRows.ts +++ b/lib/emails/valuationReport/buildValuationReleaseRows.ts @@ -1,22 +1,5 @@ import type { ReleaseRollup } from "@/lib/catalog/buildReleaseRollups"; -import type { SpotifyAlbum } from "@/lib/spotify/getAlbums"; -import type { ValuationReleaseRow } from "./renderValuationReportHtml"; - -/** - * Map each album name (trimmed, lowercased) to its smallest cover-art URL from - * a Spotify /v1/albums response. Spotify returns images largest-first, so the - * last entry is the ~64px thumbnail best suited to an email row. - */ -export function buildAlbumArtMap(albums: SpotifyAlbum[]): Map { - const map = new Map(); - for (const album of albums ?? []) { - const name = album?.name?.trim().toLowerCase(); - const images = album?.images ?? []; - const url = images[images.length - 1]?.url; - if (name && url && !map.has(name)) map.set(name, url); - } - return map; -} +import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes"; /** * Turn per-release rollups into the email's release rows: proportional-share diff --git a/lib/emails/valuationReport/formatUsd.ts b/lib/emails/valuationReport/formatUsd.ts new file mode 100644 index 00000000..f4f77528 --- /dev/null +++ b/lib/emails/valuationReport/formatUsd.ts @@ -0,0 +1,10 @@ +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; + +/** + * Format a dollar amount for the valuation email as a compact string (e.g. + * `$3.6M`). Shared by the valuation block and per-release rows so both surfaces + * render money identically. + */ +export function formatUsd(n: number): string { + return `$${formatCompactNumber(n)}`; +} diff --git a/lib/emails/valuationReport/renderArtistHeader.ts b/lib/emails/valuationReport/renderArtistHeader.ts new file mode 100644 index 00000000..0ba3e66b --- /dev/null +++ b/lib/emails/valuationReport/renderArtistHeader.ts @@ -0,0 +1,23 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * Render the artist header (avatar + name + follower count) for the valuation + * email. Returns "" when no named artist is present so the section collapses. + */ +export function renderArtistHeader(artist: ValuationReportEmailParams["artist"]): string { + if (!artist || !artist.name) return ""; + const name = escapeHtml(artist.name); + const followers = + artist.followers != null + ? `

${formatCompactNumber(artist.followers)} followers

` + : ""; + const avatar = artist.imageUrl + ? `
${name}
+${avatar} + +

${name}

${followers}
`; +} diff --git a/lib/emails/valuationReport/renderReleaseRow.ts b/lib/emails/valuationReport/renderReleaseRow.ts new file mode 100644 index 00000000..8d1f36c8 --- /dev/null +++ b/lib/emails/valuationReport/renderReleaseRow.ts @@ -0,0 +1,25 @@ +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; +import { formatUsd } from "@/lib/emails/valuationReport/formatUsd"; +import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * Render one row of the per-release table: album art thumbnail, title + artists, + * streams, and proportional-share value. Falls back to a grey square when the + * release has no cover art. + */ +export function renderReleaseRow(release: ValuationReleaseRow): string { + const album = release.album ? escapeHtml(release.album) : "Untitled release"; + const artists = release.artistNames.length + ? `

${escapeHtml(release.artistNames.join(", "))}

` + : ""; + const art = release.artUrl + ? `` + : `
`; + return ` +${art} +

${album}

${artists} +${formatCompactNumber(release.streams)} +${formatUsd(release.value)} +`; +} diff --git a/lib/emails/valuationReport/renderReleasesTable.ts b/lib/emails/valuationReport/renderReleasesTable.ts new file mode 100644 index 00000000..94124ee4 --- /dev/null +++ b/lib/emails/valuationReport/renderReleasesTable.ts @@ -0,0 +1,17 @@ +import { renderReleaseRow } from "@/lib/emails/valuationReport/renderReleaseRow"; +import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * Render the per-release table (header + one row per release). Returns "" when + * there are no releases so the section collapses. + */ +export function renderReleasesTable(releases: ValuationReleaseRow[] | undefined): string { + if (!releases || releases.length === 0) return ""; + const header = ` +Release +Streams +Value +`; + const rows = releases.map(renderReleaseRow).join(""); + return `${header}${rows}
`; +} diff --git a/lib/emails/valuationReport/renderStatRow.ts b/lib/emails/valuationReport/renderStatRow.ts new file mode 100644 index 00000000..44532ddb --- /dev/null +++ b/lib/emails/valuationReport/renderStatRow.ts @@ -0,0 +1,29 @@ +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * Render the measured-scope stat strip (lifetime streams, tracks measured, + * releases, catalog age). Each cell is omitted when its value is unavailable. + */ +export function renderStatRow(params: ValuationReportEmailParams): string { + const stats = [ + params.totalStreams != null + ? ["Lifetime streams", formatCompactNumber(params.totalStreams)] + : null, + params.measuredSongCount != null + ? ["Tracks measured", formatCompactNumber(params.measuredSongCount)] + : null, + ["Releases", formatCompactNumber(params.releaseCount ?? params.albumCount)], + params.catalogAgeYears != null + ? ["Catalog age", `${params.catalogAgeYears} year${params.catalogAgeYears === 1 ? "" : "s"}`] + : null, + ].filter((s): s is [string, string] => s !== null); + + const cells = stats + .map( + ([label, value]) => + `

${value}

${label}

`, + ) + .join(""); + return `${cells}
`; +} diff --git a/lib/emails/valuationReport/renderValuationBlock.ts b/lib/emails/valuationReport/renderValuationBlock.ts new file mode 100644 index 00000000..371da65b --- /dev/null +++ b/lib/emails/valuationReport/renderValuationBlock.ts @@ -0,0 +1,15 @@ +import { formatUsd } from "@/lib/emails/valuationReport/formatUsd"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * Render the headline "Estimated catalog value" block (central value + range). + * Returns "" when the catalog has no valuation so the section collapses. + */ +export function renderValuationBlock(valuation: ValuationReportEmailParams["valuation"]): string { + if (!valuation) return ""; + return `
+

Estimated catalog value

+

${formatUsd(valuation.mid)}

+

Range ${formatUsd(valuation.low)} to ${formatUsd(valuation.high)}

+
`; +} diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts index eb2d9fee..3325f7e0 100644 --- a/lib/emails/valuationReport/renderValuationReportHtml.ts +++ b/lib/emails/valuationReport/renderValuationReportHtml.ts @@ -1,109 +1,14 @@ import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; import { escapeHtml } from "@/lib/emails/escapeHtml"; -import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; - -export type ValuationReleaseRow = { - album: string | null; - artistNames: string[]; - streams: number; - /** Proportional share of the band's central value (streams / totalStreams x mid). */ - value: number; - artUrl: string | null; -}; - -export type ValuationReportEmailParams = { - catalogName: string | null; - deepLinkUrl: string; - albumCount: number; - artist?: { name: string | null; imageUrl: string | null; followers: number | null }; - valuation?: { low: number; mid: number; high: number }; - totalStreams?: number; - measuredSongCount?: number; - releaseCount?: number; - catalogAgeYears?: number; - releases?: ValuationReleaseRow[]; -}; +import { renderArtistHeader } from "@/lib/emails/valuationReport/renderArtistHeader"; +import { renderValuationBlock } from "@/lib/emails/valuationReport/renderValuationBlock"; +import { renderStatRow } from "@/lib/emails/valuationReport/renderStatRow"; +import { renderReleasesTable } from "@/lib/emails/valuationReport/renderReleasesTable"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; const SUBJECT = "Your catalog valuation is ready"; const FONT = "ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif"; -const usd = (n: number) => `$${formatCompactNumber(n)}`; - -function renderArtistHeader(artist: ValuationReportEmailParams["artist"]): string { - if (!artist || !artist.name) return ""; - const name = escapeHtml(artist.name); - const followers = - artist.followers != null - ? `

${formatCompactNumber(artist.followers)} followers

` - : ""; - const avatar = artist.imageUrl - ? `${name}` - : ""; - return ` -${avatar} - -

${name}

${followers}
`; -} - -function renderValuationBlock(valuation: ValuationReportEmailParams["valuation"]): string { - if (!valuation) return ""; - return `
-

Estimated catalog value

-

${usd(valuation.mid)}

-

Range ${usd(valuation.low)} to ${usd(valuation.high)}

-
`; -} - -function renderStatRow(params: ValuationReportEmailParams): string { - const stats = [ - params.totalStreams != null - ? ["Lifetime streams", formatCompactNumber(params.totalStreams)] - : null, - params.measuredSongCount != null - ? ["Tracks measured", formatCompactNumber(params.measuredSongCount)] - : null, - ["Releases", formatCompactNumber(params.releaseCount ?? params.albumCount)], - params.catalogAgeYears != null - ? ["Catalog age", `${params.catalogAgeYears} year${params.catalogAgeYears === 1 ? "" : "s"}`] - : null, - ].filter((s): s is [string, string] => s !== null); - - const cells = stats - .map( - ([label, value]) => - `

${value}

${label}

`, - ) - .join(""); - return `${cells}
`; -} - -function renderReleaseRow(release: ValuationReleaseRow): string { - const album = release.album ? escapeHtml(release.album) : "Untitled release"; - const artists = release.artistNames.length - ? `

${escapeHtml(release.artistNames.join(", "))}

` - : ""; - const art = release.artUrl - ? `` - : `
`; - return ` -${art} -

${album}

${artists} -${formatCompactNumber(release.streams)} -${usd(release.value)} -`; -} - -function renderReleasesTable(releases: ValuationReleaseRow[] | undefined): string { - if (!releases || releases.length === 0) return ""; - const header = ` -Release -Streams -Value -`; - const rows = releases.map(renderReleaseRow).join(""); - return `${header}${rows}
`; -} - /** * Deterministic house-style renderer for the valuation-report email * (recoupable/chat#1867, enriched per chat#1881): reproduces the marketing / diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts index 4b3db911..5d2a6094 100644 --- a/lib/emails/valuationReport/sendValuationReportEmail.ts +++ b/lib/emails/valuationReport/sendValuationReportEmail.ts @@ -3,22 +3,14 @@ import { CHAT_APP_URL, RECOUP_FROM_EMAIL } from "@/lib/const"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { logEmailAttempt } from "@/lib/emails/logEmailAttempt"; import { renderValuationReportHtml } from "@/lib/emails/valuationReport/renderValuationReportHtml"; -import { - buildAlbumArtMap, - buildValuationReleaseRows, -} from "@/lib/emails/valuationReport/buildValuationReleaseRows"; +import { buildReleaseRows } from "@/lib/emails/valuationReport/buildReleaseRows"; import { selectEmailSendLog } from "@/lib/supabase/email_send_log/selectEmailSendLog"; import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; -import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; -import { selectCatalogSongsWithArtists } from "@/lib/supabase/catalog_songs/selectCatalogSongsWithArtists"; import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; -import { buildReleaseRollups } from "@/lib/catalog/buildReleaseRollups"; -import generateAccessToken from "@/lib/spotify/generateAccessToken"; -import getAlbums from "@/lib/spotify/getAlbums"; -import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/renderValuationReportHtml"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; import type { SpotifyArtist } from "@/types/spotify.types"; import type { Tables } from "@/types/database.types"; @@ -27,43 +19,6 @@ export type SendValuationReportEmailResult = | { sent: false; skipped: "already_sent" | "no_email" } | { sent: false; error: string }; -// One page is enough for the report: valuation-claimed catalogs are well under -// this, and the release table only needs the measured tracks. -const MEASUREMENTS_LIMIT = 1000; - -/** - * Gather the per-release table (album, streams, proportional value, art) for a - * valued catalog. Best-effort: any read/Spotify failure returns [] so the email - * still sends with the headline band, just without the breakdown. - */ -async function buildReleaseRows( - catalogId: string, - albumIds: string[], - totalStreams: number, - bandMid: number, -): Promise { - try { - const [{ songs }, measurements, tokenResult] = await Promise.all([ - selectCatalogSongsWithArtists({ catalogId }), - selectCatalogMeasurementsPage({ catalogId, page: 1, limit: MEASUREMENTS_LIMIT }), - generateAccessToken(), - ]); - - const rollups = buildReleaseRollups(songs, measurements ?? []); - - let artByAlbum = new Map(); - if (albumIds.length > 0 && tokenResult.access_token) { - const { albums } = await getAlbums({ ids: albumIds, accessToken: tokenResult.access_token }); - if (albums) artByAlbum = buildAlbumArtMap(albums); - } - - return buildValuationReleaseRows({ rollups, totalStreams, bandMid, artByAlbum }); - } catch (error) { - console.error(`Valuation email release-table build failed for catalog ${catalogId}:`, error); - return []; - } -} - /** * Emails the valuation summary for a completed snapshot run to the owning * account (recoupable/chat#1867, enriched per chat#1881). Reproduces the diff --git a/lib/emails/valuationReport/valuationReportTypes.ts b/lib/emails/valuationReport/valuationReportTypes.ts new file mode 100644 index 00000000..4bc4821a --- /dev/null +++ b/lib/emails/valuationReport/valuationReportTypes.ts @@ -0,0 +1,27 @@ +/** + * Shared shapes for the valuation-report email (recoupable/chat#1867, enriched + * per chat#1881). Kept in their own module so the per-section render helpers and + * the top-level renderer can import them without a circular dependency. + */ + +export type ValuationReleaseRow = { + album: string | null; + artistNames: string[]; + streams: number; + /** Proportional share of the band's central value (streams / totalStreams x mid). */ + value: number; + artUrl: string | null; +}; + +export type ValuationReportEmailParams = { + catalogName: string | null; + deepLinkUrl: string; + albumCount: number; + artist?: { name: string | null; imageUrl: string | null; followers: number | null }; + valuation?: { low: number; mid: number; high: number }; + totalStreams?: number; + measuredSongCount?: number; + releaseCount?: number; + catalogAgeYears?: number; + releases?: ValuationReleaseRow[]; +}; From 82161fc83998d43809662432a14dde9e52fa09f2 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Thu, 23 Jul 2026 08:53:41 -0500 Subject: [PATCH 9/9] refactor(emails): rename formatUsd -> formatCompactUsd (disambiguate from lib/stripe/formatUsd) lib/stripe/formatUsd takes cents and renders exact $99.00 for billing; the valuation email's helper takes whole dollars and compacts ($3.6M). Same name, different unit + rounding -> rename to make intent explicit and remove the collision. No behavior change; 23 tests + tsc + lint + format clean. --- lib/emails/valuationReport/formatCompactUsd.ts | 12 ++++++++++++ lib/emails/valuationReport/formatUsd.ts | 10 ---------- lib/emails/valuationReport/renderReleaseRow.ts | 4 ++-- lib/emails/valuationReport/renderValuationBlock.ts | 6 +++--- 4 files changed, 17 insertions(+), 15 deletions(-) create mode 100644 lib/emails/valuationReport/formatCompactUsd.ts delete mode 100644 lib/emails/valuationReport/formatUsd.ts diff --git a/lib/emails/valuationReport/formatCompactUsd.ts b/lib/emails/valuationReport/formatCompactUsd.ts new file mode 100644 index 00000000..680880d3 --- /dev/null +++ b/lib/emails/valuationReport/formatCompactUsd.ts @@ -0,0 +1,12 @@ +import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; + +/** + * Format a whole-dollar amount as a compact dollar string for the valuation + * email, e.g. 3_569_477 → `$3.6M`. Distinct from `lib/stripe/formatUsd` (which + * takes cents and renders exact `$99.00` for billing) — this one takes dollars + * and compacts, for the large headline / per-release figures. Shared by the + * valuation block and per-release rows so both render money identically. + */ +export function formatCompactUsd(n: number): string { + return `$${formatCompactNumber(n)}`; +} diff --git a/lib/emails/valuationReport/formatUsd.ts b/lib/emails/valuationReport/formatUsd.ts deleted file mode 100644 index f4f77528..00000000 --- a/lib/emails/valuationReport/formatUsd.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; - -/** - * Format a dollar amount for the valuation email as a compact string (e.g. - * `$3.6M`). Shared by the valuation block and per-release rows so both surfaces - * render money identically. - */ -export function formatUsd(n: number): string { - return `$${formatCompactNumber(n)}`; -} diff --git a/lib/emails/valuationReport/renderReleaseRow.ts b/lib/emails/valuationReport/renderReleaseRow.ts index 8d1f36c8..72ad1d85 100644 --- a/lib/emails/valuationReport/renderReleaseRow.ts +++ b/lib/emails/valuationReport/renderReleaseRow.ts @@ -1,6 +1,6 @@ import { escapeHtml } from "@/lib/emails/escapeHtml"; import { formatCompactNumber } from "@/lib/emails/valuationReport/formatCompactNumber"; -import { formatUsd } from "@/lib/emails/valuationReport/formatUsd"; +import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes"; /** @@ -20,6 +20,6 @@ export function renderReleaseRow(release: ValuationReleaseRow): string { ${art}

${album}

${artists} ${formatCompactNumber(release.streams)} -${formatUsd(release.value)} +${formatCompactUsd(release.value)} `; } diff --git a/lib/emails/valuationReport/renderValuationBlock.ts b/lib/emails/valuationReport/renderValuationBlock.ts index 371da65b..cf5c693b 100644 --- a/lib/emails/valuationReport/renderValuationBlock.ts +++ b/lib/emails/valuationReport/renderValuationBlock.ts @@ -1,4 +1,4 @@ -import { formatUsd } from "@/lib/emails/valuationReport/formatUsd"; +import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; /** @@ -9,7 +9,7 @@ export function renderValuationBlock(valuation: ValuationReportEmailParams["valu if (!valuation) return ""; return `

Estimated catalog value

-

${formatUsd(valuation.mid)}

-

Range ${formatUsd(valuation.low)} to ${formatUsd(valuation.high)}

+

${formatCompactUsd(valuation.mid)}

+

Range ${formatCompactUsd(valuation.low)} to ${formatCompactUsd(valuation.high)}

`; }