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 00000000..3f311add Binary files /dev/null and b/lib/catalog/buildReleaseRollups.ts differ 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 new file mode 100644 index 00000000..9db0e93c --- /dev/null +++ b/lib/emails/valuationReport/__tests__/buildValuationReleaseRows.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "vitest"; +import { buildValuationReleaseRows } from "../buildValuationReleaseRows"; + +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__/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..bcde9497 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/renderValuationReportHtml.test.ts @@ -0,0 +1,109 @@ +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, + 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", () => { + 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("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("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", () => { + const { html } = renderValuationReportHtml({ + ...baseParams, + catalogName: 'Catalog', + }); + expect(html).not.toContain('Catalog'); + expect(html).toContain("<img src="x">Catalog"); + }); + + 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 catalog value"); + expect(html).not.toContain("Directional model"); + expect(html).toContain("Your catalog"); + expect(html).toContain('href="https://chat.recoupable.dev"'); + }); + + 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..22ae78c3 --- /dev/null +++ b/lib/emails/valuationReport/__tests__/sendValuationReportEmail.test.ts @@ -0,0 +1,185 @@ +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 { 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 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() })); +vi.mock("@/lib/emails/logEmailAttempt", () => ({ logEmailAttempt: 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() })); +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([ + { 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(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" }); +} + +describe("sendValuationReportEmail", () => { + beforeEach(() => { + vi.clearAllMocks(); + arrange(); + }); + + 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]; + 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"); + // 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" }); + + 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(selectEmailSendLog).mockResolvedValue([{ id: "log_1" } as Tables<"email_send_log">]); + + const result = await sendValuationReportEmail(snapshot, { artist }); + + 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, { artist }); + + expect(sendEmailWithResend).not.toHaveBeenCalled(); + expect(result).toEqual({ sent: false, skipped: "no_email" }); + }); + + it("falls back to the app link 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("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, { artist }); + + 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/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 new file mode 100644 index 00000000..2f29a9c6 --- /dev/null +++ b/lib/emails/valuationReport/buildValuationReleaseRows.ts @@ -0,0 +1,24 @@ +import type { ReleaseRollup } from "@/lib/catalog/buildReleaseRollups"; +import type { ValuationReleaseRow } from "@/lib/emails/valuationReport/valuationReportTypes"; + +/** + * 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/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/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/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}` + : ""; + return ` +${avatar} + +

${name}

${followers}
`; +} diff --git a/lib/emails/valuationReport/renderReleaseRow.ts b/lib/emails/valuationReport/renderReleaseRow.ts new file mode 100644 index 00000000..72ad1d85 --- /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 { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; +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)} +${formatCompactUsd(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..cf5c693b --- /dev/null +++ b/lib/emails/valuationReport/renderValuationBlock.ts @@ -0,0 +1,15 @@ +import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; +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

+

${formatCompactUsd(valuation.mid)}

+

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

+
`; +} diff --git a/lib/emails/valuationReport/renderValuationReportHtml.ts b/lib/emails/valuationReport/renderValuationReportHtml.ts new file mode 100644 index 00000000..3325f7e0 --- /dev/null +++ b/lib/emails/valuationReport/renderValuationReportHtml.ts @@ -0,0 +1,56 @@ +import { RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; +import { escapeHtml } from "@/lib/emails/escapeHtml"; +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"; + +/** + * 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 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 = `
+ +
+ + + +
+

Catalog valuation

+

${name}

+
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.

+
+
`; + + return { subject: SUBJECT, html }; +} diff --git a/lib/emails/valuationReport/sendValuationReportEmail.ts b/lib/emails/valuationReport/sendValuationReportEmail.ts new file mode 100644 index 00000000..5d2a6094 --- /dev/null +++ b/lib/emails/valuationReport/sendValuationReportEmail.ts @@ -0,0 +1,134 @@ +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 { 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 { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; +import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; +import type { ValuationReportEmailParams } from "@/lib/emails/valuationReport/valuationReportTypes"; +import type { SpotifyArtist } from "@/types/spotify.types"; +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, 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 (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). + const alreadySent = await selectEmailSendLog({ + status: "sent", + rawBodyLike: `"snapshot_id":"${snapshot.id}"`, + limit: 1, + }); + if (alreadySent.length > 0) { + 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 (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([ + 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; + + 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); + } + } + + 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/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[]; +}; 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. diff --git a/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts new file mode 100644 index 00000000..97283808 --- /dev/null +++ b/lib/supabase/email_send_log/__tests__/selectEmailSendLog.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectEmailSendLog } from "../selectEmailSendLog"; +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("selectEmailSendLog", () => { + beforeEach(() => vi.clearAllMocks()); + + 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 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(builder.limit).toHaveBeenCalledWith(1); + expect(result).toEqual(rows); + }); + + 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 selectEmailSendLog({ status: "sent" })).toEqual([]); + }); + + it("returns an empty array on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mockBuilder({ data: null, error: { message: "boom" } }); + 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/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,