From fac09c733ce2772073f1f69759b1a82f7724e558 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Mon, 6 Jul 2026 12:36:18 -0500 Subject: [PATCH] =?UTF-8?q?feat(catalogs):=20GET=20/api/catalogs/measureme?= =?UTF-8?q?nts=20=E2=80=94=20latest=20per-ISRC=20playcounts=20+=20derived?= =?UTF-8?q?=20valuation=20band=20(chat#1850)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the docs#265 contract: catalog ownership from auth (no IDOR), catalog_songs → latest spotify platform_displayed_play_count per ISRC, and a valuation band { low, mid, high } derived at read time with the marketing card's exact model (lifetime-average run-rate over catalog age from the source run's Spotify release dates, NLS, 10/13/16x multiples). TDD red→green per unit; full suite 3923 pass; tsc adds no new errors; lint + prettier clean. Co-Authored-By: Claude Fable 5 --- app/api/catalogs/measurements/route.ts | 26 +++ .../__tests__/computeValuationBand.test.ts | 54 ++++++ .../getCatalogEarliestReleaseDate.test.ts | 74 +++++++++ .../getCatalogMeasurementsHandler.test.ts | 154 ++++++++++++++++++ .../latestMeasurementsPerIsrc.test.ts | 59 +++++++ ...alidateGetCatalogMeasurementsQuery.test.ts | 35 ++++ lib/catalog/computeValuationBand.ts | 48 ++++++ lib/catalog/getCatalogEarliestReleaseDate.ts | 31 ++++ lib/catalog/getCatalogMeasurementsHandler.ts | 73 +++++++++ lib/catalog/latestMeasurementsPerIsrc.ts | 35 ++++ .../validateGetCatalogMeasurementsQuery.ts | 39 +++++ lib/spotify/__tests__/getAlbums.test.ts | 52 ++++++ lib/spotify/getAlbums.ts | 54 ++++++ .../__tests__/selectAccountCatalog.test.ts | 46 ++++++ .../account_catalogs/selectAccountCatalog.ts | 33 ++++ .../__tests__/selectCatalogSongTitles.test.ts | 49 ++++++ .../catalog_songs/selectCatalogSongTitles.ts | 25 +++ .../selectPlaycountSnapshots.test.ts | 10 ++ .../selectPlaycountSnapshots.ts | 4 + 19 files changed, 901 insertions(+) create mode 100644 app/api/catalogs/measurements/route.ts create mode 100644 lib/catalog/__tests__/computeValuationBand.test.ts create mode 100644 lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts create mode 100644 lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts create mode 100644 lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts create mode 100644 lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts create mode 100644 lib/catalog/computeValuationBand.ts create mode 100644 lib/catalog/getCatalogEarliestReleaseDate.ts create mode 100644 lib/catalog/getCatalogMeasurementsHandler.ts create mode 100644 lib/catalog/latestMeasurementsPerIsrc.ts create mode 100644 lib/catalog/validateGetCatalogMeasurementsQuery.ts create mode 100644 lib/spotify/__tests__/getAlbums.test.ts create mode 100644 lib/spotify/getAlbums.ts create mode 100644 lib/supabase/account_catalogs/__tests__/selectAccountCatalog.test.ts create mode 100644 lib/supabase/account_catalogs/selectAccountCatalog.ts create mode 100644 lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts create mode 100644 lib/supabase/catalog_songs/selectCatalogSongTitles.ts diff --git a/app/api/catalogs/measurements/route.ts b/app/api/catalogs/measurements/route.ts new file mode 100644 index 00000000..9ee1dfe9 --- /dev/null +++ b/app/api/catalogs/measurements/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getCatalogMeasurementsHandler } from "@/lib/catalog/getCatalogMeasurementsHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET handler for retrieving a catalog's latest per-song play counts and the + * derived valuation band. + * + * @param request - The request object containing the catalogId query parameter. + * @returns A NextResponse with measurements, valuation band, and totals. + */ +export async function GET(request: NextRequest) { + return getCatalogMeasurementsHandler(request); +} diff --git a/lib/catalog/__tests__/computeValuationBand.test.ts b/lib/catalog/__tests__/computeValuationBand.test.ts new file mode 100644 index 00000000..7af15a96 --- /dev/null +++ b/lib/catalog/__tests__/computeValuationBand.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { computeValuationBand } from "../computeValuationBand"; + +describe("computeValuationBand", () => { + it("computes the band from lifetime streams + catalog age (marketing card model)", () => { + // 100M lifetime Spotify plays, catalog ~10 years old + const v = computeValuationBand({ + totalStreams: 100_000_000, + earliestReleaseDate: "2016-06-12", + now: new Date("2026-06-12"), + }); + + // annual proxy = 100M / 10y = 10M streams/yr + // spotify gross/yr = 10M x $0.0035 = $35,000 + // total gross band = spotify x (1.25 / 1.40 / 1.60) + // NLS = gross x 0.85 x 0.75; value = NLS x (10 / 13 / 16) + expect(v.catalogAgeYears).toBe(10); + expect(v.valuation.low).toBeCloseTo(35_000 * 1.25 * 0.85 * 0.75 * 10, 0); + expect(v.valuation.mid).toBeCloseTo(35_000 * 1.4 * 0.85 * 0.75 * 13, 0); + expect(v.valuation.high).toBeCloseTo(35_000 * 1.6 * 0.85 * 0.75 * 16, 0); + }); + + it("clamps catalog age to at least one year", () => { + const v = computeValuationBand({ + totalStreams: 1_000_000, + earliestReleaseDate: "2026-01-01", + now: new Date("2026-06-12"), + }); + + expect(v.catalogAgeYears).toBe(1); + // age 1y: annual proxy = lifetime + expect(v.valuation.mid).toBeCloseTo(1_000_000 * 0.0035 * 1.4 * 0.85 * 0.75 * 13, 0); + }); + + it("returns a zero band for zero streams", () => { + const v = computeValuationBand({ + totalStreams: 0, + earliestReleaseDate: "2020-01-01", + now: new Date("2026-06-12"), + }); + + expect(v.valuation).toEqual({ low: 0, mid: 0, high: 0 }); + }); + + it("falls back to the 5y default age when no release date is known", () => { + const v = computeValuationBand({ + totalStreams: 50_000_000, + earliestReleaseDate: null, + now: new Date("2026-06-12"), + }); + + expect(v.catalogAgeYears).toBe(5); + }); +}); diff --git a/lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts b/lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts new file mode 100644 index 00000000..0b0e5a13 --- /dev/null +++ b/lib/catalog/__tests__/getCatalogEarliestReleaseDate.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate"; +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbums from "@/lib/spotify/getAlbums"; + +vi.mock("@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots", () => ({ + selectPlaycountSnapshots: vi.fn(), +})); +vi.mock("@/lib/spotify/generateAccessToken", () => ({ default: vi.fn() })); +vi.mock("@/lib/spotify/getAlbums", () => ({ default: vi.fn() })); + +const snapshot = (albumIds: string[] | null) => ({ id: "snap_1", album_ids: albumIds }); + +describe("getCatalogEarliestReleaseDate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the earliest release date across the source run's albums", async () => { + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1", "a2"])] as never); + vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never); + vi.mocked(getAlbums).mockResolvedValue({ + albums: [ + { id: "a1", release_date: "2019-03-01" }, + { id: "a2", release_date: "2015-06-12" }, + ], + error: null, + }); + + const result = await getCatalogEarliestReleaseDate("cat_1"); + + expect(selectPlaycountSnapshots).toHaveBeenCalledWith({ catalog: "cat_1" }); + expect(getAlbums).toHaveBeenCalledWith({ ids: ["a1", "a2"], accessToken: "tok" }); + expect(result).toBe("2015-06-12"); + }); + + it("uses the newest snapshot that has album ids", async () => { + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + snapshot(null), + snapshot(["a1"]), + ] as never); + vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never); + vi.mocked(getAlbums).mockResolvedValue({ + albums: [{ id: "a1", release_date: "2021-01-01" }], + error: null, + }); + + expect(await getCatalogEarliestReleaseDate("cat_1")).toBe("2021-01-01"); + }); + + it("returns null when no snapshot has album ids", async () => { + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(null)] as never); + + expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull(); + expect(generateAccessToken).not.toHaveBeenCalled(); + }); + + it("returns null when the Spotify token cannot be generated", async () => { + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1"])] as never); + vi.mocked(generateAccessToken).mockResolvedValue({ + access_token: null, + error: new Error("nope"), + } as never); + + expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull(); + }); + + it("returns null when the albums fetch fails", async () => { + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([snapshot(["a1"])] as never); + vi.mocked(generateAccessToken).mockResolvedValue({ access_token: "tok" } as never); + vi.mocked(getAlbums).mockResolvedValue({ albums: null, error: new Error("boom") }); + + expect(await getCatalogEarliestReleaseDate("cat_1")).toBeNull(); + }); +}); diff --git a/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts new file mode 100644 index 00000000..17149e89 --- /dev/null +++ b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getCatalogMeasurementsHandler } from "../getCatalogMeasurementsHandler"; +import { validateGetCatalogMeasurementsQuery } from "../validateGetCatalogMeasurementsQuery"; +import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; +import { selectCatalogSongTitles } from "@/lib/supabase/catalog_songs/selectCatalogSongTitles"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("../validateGetCatalogMeasurementsQuery", () => ({ + validateGetCatalogMeasurementsQuery: vi.fn(), +})); +vi.mock("../getCatalogEarliestReleaseDate", () => ({ getCatalogEarliestReleaseDate: vi.fn() })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/supabase/account_catalogs/selectAccountCatalog", () => ({ + selectAccountCatalog: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_songs/selectCatalogSongTitles", () => ({ + selectCatalogSongTitles: vi.fn(), +})); +vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ + selectSongMeasurements: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + +const makeRequest = () => + new NextRequest(`http://localhost/api/catalogs/measurements?catalogId=${catalogId}`); + +const okAuth = () => + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "t", + } as never); + +const okQuery = () => vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue({ catalogId }); + +describe("getCatalogMeasurementsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits with the validator error and never authenticates", async () => { + const err = NextResponse.json({ status: "error" }, { status: 400 }); + vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue(err); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + + expect(res).toBe(err); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("short-circuits with the auth error when unauthenticated", async () => { + okQuery(); + const authErr = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + + expect(res).toBe(authErr); + expect(selectAccountCatalog).not.toHaveBeenCalled(); + }); + + it("returns 404 when the catalog does not belong to the caller", async () => { + okQuery(); + okAuth(); + vi.mocked(selectAccountCatalog).mockResolvedValue(null); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + + expect(res.status).toBe(404); + expect(selectAccountCatalog).toHaveBeenCalledWith({ accountId, catalogId }); + expect(selectCatalogSongTitles).not.toHaveBeenCalled(); + }); + + it("returns latest per-ISRC measurements + the derived valuation band", async () => { + okQuery(); + okAuth(); + vi.mocked(selectAccountCatalog).mockResolvedValue({ + account: accountId, + catalog: catalogId, + } as never); + vi.mocked(selectCatalogSongTitles).mockResolvedValue([ + { isrc: "ISRC1", title: "Song One" }, + { isrc: "ISRC2", title: "Song Two" }, + ]); + vi.mocked(selectSongMeasurements).mockResolvedValue([ + // newest-first series; ISRC1 has an older superseded capture + { song: "ISRC1", value: 200, captured_at: "2026-07-01T00:00:00Z" }, + { song: "ISRC2", value: 50, captured_at: "2026-07-01T00:00:00Z" }, + { song: "ISRC1", value: 100, captured_at: "2026-06-01T00:00:00Z" }, + ] as never); + // 10 years of catalog age + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(selectSongMeasurements).toHaveBeenCalledWith({ + songs: ["ISRC1", "ISRC2"], + platform: "spotify", + metric: "platform_displayed_play_count", + }); + expect(body.status).toBe("success"); + expect(body.measurements).toEqual([ + { isrc: "ISRC1", title: "Song One", playcount: 200, measured_at: "2026-07-01T00:00:00Z" }, + { isrc: "ISRC2", title: "Song Two", playcount: 50, measured_at: "2026-07-01T00:00:00Z" }, + ]); + expect(body.total_streams).toBe(250); + expect(body.catalog_age_years).toBe(10); + // 250 streams / 10y * $0.0035 * gross-up * 0.6375 net * multiple + expect(body.valuation.low).toBeCloseTo(25 * 0.0035 * 1.25 * 0.6375 * 10, 5); + expect(body.valuation.mid).toBeCloseTo(25 * 0.0035 * 1.4 * 0.6375 * 13, 5); + expect(body.valuation.high).toBeCloseTo(25 * 0.0035 * 1.6 * 0.6375 * 16, 5); + }); + + it("returns an empty result with a zero band for a catalog with no measurements", async () => { + okQuery(); + okAuth(); + vi.mocked(selectAccountCatalog).mockResolvedValue({ + account: accountId, + catalog: catalogId, + } as never); + vi.mocked(selectCatalogSongTitles).mockResolvedValue([]); + vi.mocked(selectSongMeasurements).mockResolvedValue([]); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(body.measurements).toEqual([]); + expect(body.total_streams).toBe(0); + expect(body.valuation).toEqual({ low: 0, mid: 0, high: 0 }); + }); + + it("returns 500 when a dependency throws", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + okQuery(); + okAuth(); + vi.mocked(selectAccountCatalog).mockRejectedValue(new Error("boom")); + + const res = await getCatalogMeasurementsHandler(makeRequest()); + + expect(res.status).toBe(500); + consoleError.mockRestore(); + }); +}); diff --git a/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts b/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts new file mode 100644 index 00000000..6ff0b350 --- /dev/null +++ b/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { latestMeasurementsPerIsrc } from "../latestMeasurementsPerIsrc"; +import type { Tables } from "@/types/database.types"; + +const row = (song: string, value: number, captured_at: string): Tables<"song_measurements"> => + ({ + id: `${song}-${captured_at}`, + song, + value, + captured_at, + created_at: captured_at, + data_source: "spotify_web", + metric: "platform_displayed_play_count", + platform: "spotify", + raw_ref: null, + snapshot: null, + }) as Tables<"song_measurements">; + +describe("latestMeasurementsPerIsrc", () => { + it("keeps only the newest capture per ISRC and sums the total", () => { + // rows arrive newest-first (selectSongMeasurements orders captured_at desc) + const rows = [ + row("ISRC1", 200, "2026-07-01T00:00:00Z"), + row("ISRC2", 50, "2026-07-01T00:00:00Z"), + row("ISRC1", 100, "2026-06-01T00:00:00Z"), + ]; + const titles = new Map([ + ["ISRC1", "Song One"], + ["ISRC2", null], + ]); + + const result = latestMeasurementsPerIsrc(rows, titles); + + expect(result.totalStreams).toBe(250); + expect(result.measurements).toEqual([ + { isrc: "ISRC1", title: "Song One", playcount: 200, measured_at: "2026-07-01T00:00:00Z" }, + { isrc: "ISRC2", title: null, playcount: 50, measured_at: "2026-07-01T00:00:00Z" }, + ]); + }); + + it("sorts measurements by playcount descending", () => { + const rows = [ + row("ISRC1", 10, "2026-07-01T00:00:00Z"), + row("ISRC2", 999, "2026-07-01T00:00:00Z"), + ]; + + const result = latestMeasurementsPerIsrc(rows, new Map()); + + expect(result.measurements.map(m => m.isrc)).toEqual(["ISRC2", "ISRC1"]); + expect(result.measurements[0].title).toBeNull(); + }); + + it("returns an empty result for no rows", () => { + expect(latestMeasurementsPerIsrc([], new Map())).toEqual({ + measurements: [], + totalStreams: 0, + }); + }); +}); diff --git a/lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts b/lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts new file mode 100644 index 00000000..4748de47 --- /dev/null +++ b/lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from "vitest"; +import { NextResponse } from "next/server"; +import { validateGetCatalogMeasurementsQuery } from "../validateGetCatalogMeasurementsQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + +describe("validateGetCatalogMeasurementsQuery", () => { + it("returns the validated query for a valid catalogId", () => { + const result = validateGetCatalogMeasurementsQuery(new URLSearchParams({ catalogId })); + + expect(result).toEqual({ catalogId }); + }); + + it("returns 400 when catalogId is missing", async () => { + const result = validateGetCatalogMeasurementsQuery(new URLSearchParams()); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + + it("returns 400 when catalogId is not a uuid", () => { + const result = validateGetCatalogMeasurementsQuery( + new URLSearchParams({ catalogId: "not-a-uuid" }), + ); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/catalog/computeValuationBand.ts b/lib/catalog/computeValuationBand.ts new file mode 100644 index 00000000..b4708be5 --- /dev/null +++ b/lib/catalog/computeValuationBand.ts @@ -0,0 +1,48 @@ +export type ValuationBand = { low: number; mid: number; high: number }; + +// Mirrors the marketing valuation card's model exactly +// (marketing/lib/valuation/computeCatalogValuation.ts + nlsBandFromSpotifyGross.ts) +// so marketing and chat can never drift. Change constants in both places or not at all. +const SPOTIFY_PER_STREAM_USD = 0.0035; +const GROSS_UP = { low: 1.25, mid: 1.4, high: 1.6 }; +const DISTRIBUTION_FEE = 0.15; +const ROYALTY_SHARE = 0.25; +const MULTIPLE = { low: 10, mid: 13, high: 16 }; +const DEFAULT_AGE_YEARS = 5; +const YEAR_MS = 365.25 * 24 * 60 * 60 * 1000; + +/** + * Derive a catalog valuation band from lifetime Spotify play counts — the same + * model as the recoupable.dev valuation card: annual run-rate via the + * lifetime-average proxy (all-time streams / catalog age), converted to net + * label share and multiplied by a 10-16x master-catalog market multiple. + * + * @param params.totalStreams - Sum of the latest play counts across the catalog + * @param params.earliestReleaseDate - Earliest release date (ISO); null falls back to a 5y default age + * @param params.now - Clock override for tests + */ +export function computeValuationBand(params: { + totalStreams: number; + earliestReleaseDate: string | null; + now?: Date; +}): { valuation: ValuationBand; catalogAgeYears: number } { + const now = params.now ?? new Date(); + + let catalogAgeYears = DEFAULT_AGE_YEARS; + if (params.earliestReleaseDate) { + const ageMs = now.getTime() - new Date(params.earliestReleaseDate).getTime(); + catalogAgeYears = Math.max(1, Math.round(ageMs / YEAR_MS)); + } + + const annualGross = (params.totalStreams / catalogAgeYears) * SPOTIFY_PER_STREAM_USD; + const net = (1 - DISTRIBUTION_FEE) * (1 - ROYALTY_SHARE); + + return { + valuation: { + low: annualGross * GROSS_UP.low * net * MULTIPLE.low, + mid: annualGross * GROSS_UP.mid * net * MULTIPLE.mid, + high: annualGross * GROSS_UP.high * net * MULTIPLE.high, + }, + catalogAgeYears, + }; +} diff --git a/lib/catalog/getCatalogEarliestReleaseDate.ts b/lib/catalog/getCatalogEarliestReleaseDate.ts new file mode 100644 index 00000000..d6867727 --- /dev/null +++ b/lib/catalog/getCatalogEarliestReleaseDate.ts @@ -0,0 +1,31 @@ +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import generateAccessToken from "@/lib/spotify/generateAccessToken"; +import getAlbums from "@/lib/spotify/getAlbums"; + +/** + * Resolve the earliest Spotify release date for a catalog — the catalog-age + * input to the valuation band. Release dates aren't persisted, so this reads + * the album ids off the catalog's newest measurement run (playcount_snapshots) + * and asks Spotify. Best-effort: any gap (no snapshot, no album ids, token or + * fetch failure) returns null and the caller falls back to the default age. + * + * @param catalogId - The catalog whose source run to inspect + * @returns Earliest release date (ISO, Spotify precision), or null + */ +export async function getCatalogEarliestReleaseDate(catalogId: string): Promise { + const snapshots = await selectPlaycountSnapshots({ catalog: catalogId }); + const albumIds = snapshots.find(s => s.album_ids && s.album_ids.length > 0)?.album_ids; + if (!albumIds) return null; + + const { access_token } = await generateAccessToken(); + if (!access_token) return null; + + const { albums } = await getAlbums({ ids: albumIds, accessToken: access_token }); + if (!albums) return null; + + const releaseDates = albums + .map(a => a.release_date) + .filter((d): d is string => Boolean(d)) + .sort(); + return releaseDates[0] ?? null; +} diff --git a/lib/catalog/getCatalogMeasurementsHandler.ts b/lib/catalog/getCatalogMeasurementsHandler.ts new file mode 100644 index 00000000..dcd8dfe0 --- /dev/null +++ b/lib/catalog/getCatalogMeasurementsHandler.ts @@ -0,0 +1,73 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateGetCatalogMeasurementsQuery } from "./validateGetCatalogMeasurementsQuery"; +import { latestMeasurementsPerIsrc } from "./latestMeasurementsPerIsrc"; +import { computeValuationBand } from "./computeValuationBand"; +import { getCatalogEarliestReleaseDate } from "./getCatalogEarliestReleaseDate"; +import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; +import { selectCatalogSongTitles } from "@/lib/supabase/catalog_songs/selectCatalogSongTitles"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +/** + * GET /api/catalogs/measurements?catalogId= + * + * Latest Spotify play count per song (ISRC) in a catalog plus a valuation + * band derived at read time with the same model as the marketing valuation + * card. The account is resolved from credentials (Privy bearer or x-api-key); + * a catalog that doesn't exist or belongs to another account is a 404. + * + * @param request - The request object + * @returns `{ status, measurements, valuation, total_streams, catalog_age_years }` + */ +export async function getCatalogMeasurementsHandler(request: NextRequest): Promise { + try { + const { searchParams } = new URL(request.url); + const validated = validateGetCatalogMeasurementsQuery(searchParams); + if (validated instanceof NextResponse) { + return validated; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + const { catalogId } = validated; + + const link = await selectAccountCatalog({ accountId, catalogId }); + if (!link) { + return errorResponse("Catalog not found", 404); + } + + const songs = await selectCatalogSongTitles(catalogId); + const titles = new Map(songs.map(s => [s.isrc, s.title])); + const [rows, earliestReleaseDate] = await Promise.all([ + songs.length > 0 + ? selectSongMeasurements({ + songs: songs.map(s => s.isrc), + platform: "spotify", + metric: "platform_displayed_play_count", + }) + : Promise.resolve([]), + getCatalogEarliestReleaseDate(catalogId), + ]); + + const { measurements, totalStreams } = latestMeasurementsPerIsrc(rows, titles); + const { valuation, catalogAgeYears } = computeValuationBand({ + totalStreams, + earliestReleaseDate, + }); + + return successResponse({ + measurements, + valuation, + total_streams: totalStreams, + catalog_age_years: catalogAgeYears, + }); + } catch (error) { + console.error("Error fetching catalog measurements:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/catalog/latestMeasurementsPerIsrc.ts b/lib/catalog/latestMeasurementsPerIsrc.ts new file mode 100644 index 00000000..3774bf38 --- /dev/null +++ b/lib/catalog/latestMeasurementsPerIsrc.ts @@ -0,0 +1,35 @@ +import type { Tables } from "@/types/database.types"; + +export type CatalogTrackMeasurement = { + isrc: string; + title: string | null; + playcount: number; + measured_at: string; +}; + +/** + * Reduce a newest-first measurement series to the latest capture per ISRC, + * shaped for the response, sorted by playcount descending, plus the total. + * + * @param rows - Measurement rows ordered captured_at desc (selectSongMeasurements order) + * @param titles - Song titles by ISRC (from the catalog's songs) + */ +export function latestMeasurementsPerIsrc( + rows: Tables<"song_measurements">[], + titles: Map, +): { measurements: CatalogTrackMeasurement[]; totalStreams: number } { + const latest = new Map(); + for (const row of rows) { + if (latest.has(row.song)) continue; // newest-first: first row per ISRC wins + latest.set(row.song, { + isrc: row.song, + title: titles.get(row.song) ?? null, + playcount: row.value, + measured_at: row.captured_at, + }); + } + + const measurements = [...latest.values()].sort((a, b) => b.playcount - a.playcount); + const totalStreams = measurements.reduce((sum, m) => sum + m.playcount, 0); + return { measurements, totalStreams }; +} diff --git a/lib/catalog/validateGetCatalogMeasurementsQuery.ts b/lib/catalog/validateGetCatalogMeasurementsQuery.ts new file mode 100644 index 00000000..0fc7801c --- /dev/null +++ b/lib/catalog/validateGetCatalogMeasurementsQuery.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const getCatalogMeasurementsQuerySchema = z.object({ + catalogId: z + .string({ message: "catalogId parameter is required" }) + .uuid("catalogId must be a valid UUID"), +}); + +export type GetCatalogMeasurementsQuery = z.infer; + +/** + * Validates query parameters for GET /api/catalogs/measurements. + * + * @param searchParams - The URL search parameters to validate. + * @returns A NextResponse with an error if validation fails, or the validated query. + */ +export function validateGetCatalogMeasurementsQuery( + searchParams: URLSearchParams, +): NextResponse | GetCatalogMeasurementsQuery { + const result = getCatalogMeasurementsQuerySchema.safeParse( + Object.fromEntries(searchParams.entries()), + ); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return result.data; +} diff --git a/lib/spotify/__tests__/getAlbums.test.ts b/lib/spotify/__tests__/getAlbums.test.ts new file mode 100644 index 00000000..3f46ab0c --- /dev/null +++ b/lib/spotify/__tests__/getAlbums.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import getAlbums from "../getAlbums"; + +const albumsPage = (ids: string[]) => ({ + albums: ids.map(id => ({ id, release_date: "2020-01-01" })), +}); + +describe("getAlbums", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("fetches albums in batches of 20 per Spotify request", async () => { + const ids = Array.from({ length: 25 }, (_, i) => `album${i}`); + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify(albumsPage(ids.slice(0, 20))), { status: 200 }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify(albumsPage(ids.slice(20))), { status: 200 }), + ); + + const { albums, error } = await getAlbums({ ids, accessToken: "tok" }); + + expect(error).toBeNull(); + expect(albums).toHaveLength(25); + expect(fetch).toHaveBeenCalledTimes(2); + const firstUrl = vi.mocked(fetch).mock.calls[0][0] as string; + expect(firstUrl).toContain("https://api.spotify.com/v1/albums?ids="); + expect(decodeURIComponent(firstUrl)).toContain(ids.slice(0, 20).join(",")); + }); + + it("returns an error when a Spotify request fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce(new Response("nope", { status: 500 })); + + const { albums, error } = await getAlbums({ ids: ["a1"], accessToken: "tok" }); + + expect(albums).toBeNull(); + expect(error).toBeInstanceOf(Error); + }); + + it("returns [] for no ids without calling Spotify", async () => { + const { albums, error } = await getAlbums({ ids: [], accessToken: "tok" }); + + expect(albums).toEqual([]); + expect(error).toBeNull(); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/spotify/getAlbums.ts b/lib/spotify/getAlbums.ts new file mode 100644 index 00000000..752b2b45 --- /dev/null +++ b/lib/spotify/getAlbums.ts @@ -0,0 +1,54 @@ +export type SpotifyAlbum = { + id: string; + name?: string; + release_date?: string; +}; + +// Spotify's GET /v1/albums caps ids at 20 per request. +const BATCH_SIZE = 20; + +/** + * Fetch several Spotify albums by id via the batch endpoint (20 ids per + * request), instead of one GET /v1/albums/{id} call each. + * + * @param params.ids - Spotify album ids + * @param params.accessToken - Client-credentials access token + */ +const getAlbums = async ({ + ids, + accessToken, +}: { + ids: string[]; + accessToken: string; +}): Promise<{ albums: SpotifyAlbum[]; error: null } | { albums: null; error: Error }> => { + try { + const albums: SpotifyAlbum[] = []; + for (let i = 0; i < ids.length; i += BATCH_SIZE) { + const batch = ids.slice(i, i + BATCH_SIZE); + const url = `https://api.spotify.com/v1/albums?ids=${encodeURIComponent(batch.join(","))}`; + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + return { albums: null, error: new Error("Spotify API request failed") }; + } + + const data = await response.json(); + albums.push(...((data.albums ?? []).filter(Boolean) as SpotifyAlbum[])); + } + return { albums, error: null }; + } catch (error) { + console.error(error); + return { + albums: null, + error: error instanceof Error ? error : new Error("Unknown error fetching albums"), + }; + } +}; + +export default getAlbums; diff --git a/lib/supabase/account_catalogs/__tests__/selectAccountCatalog.test.ts b/lib/supabase/account_catalogs/__tests__/selectAccountCatalog.test.ts new file mode 100644 index 00000000..9648faf6 --- /dev/null +++ b/lib/supabase/account_catalogs/__tests__/selectAccountCatalog.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectAccountCatalog } from "../selectAccountCatalog"; +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> = {} as never; + for (const m of ["select", "eq"]) builder[m] = vi.fn().mockReturnValue(builder); + builder.maybeSingle = vi.fn().mockResolvedValue(result); + vi.mocked(supabase.from).mockReturnValue(builder as never); + return builder; +} + +describe("selectAccountCatalog", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the link row when the catalog belongs to the account", async () => { + const link = { account: "acc_1", catalog: "cat_1" }; + const builder = mockBuilder({ data: link, error: null }); + + const result = await selectAccountCatalog({ accountId: "acc_1", catalogId: "cat_1" }); + + expect(supabase.from).toHaveBeenCalledWith("account_catalogs"); + expect(builder.eq).toHaveBeenCalledWith("account", "acc_1"); + expect(builder.eq).toHaveBeenCalledWith("catalog", "cat_1"); + expect(result).toEqual(link); + }); + + it("returns null when no link exists", async () => { + mockBuilder({ data: null, error: null }); + + expect(await selectAccountCatalog({ accountId: "acc_1", catalogId: "cat_x" })).toBeNull(); + }); + + it("returns null on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mockBuilder({ data: null, error: { message: "boom" } }); + + expect(await selectAccountCatalog({ accountId: "acc_1", catalogId: "cat_1" })).toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/account_catalogs/selectAccountCatalog.ts b/lib/supabase/account_catalogs/selectAccountCatalog.ts new file mode 100644 index 00000000..2f8b9004 --- /dev/null +++ b/lib/supabase/account_catalogs/selectAccountCatalog.ts @@ -0,0 +1,33 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select the account_catalogs link for one account + catalog pair — the + * ownership check for catalog reads (no IDOR: a catalog owned by another + * account returns null, indistinguishable from not existing). + * + * @param params.accountId - The authenticated account + * @param params.catalogId - The catalog to check + * @returns The link row, or null when absent or on error + */ +export async function selectAccountCatalog({ + accountId, + catalogId, +}: { + accountId: string; + catalogId: string; +}): Promise | null> { + const { data, error } = await supabase + .from("account_catalogs") + .select("*") + .eq("account", accountId) + .eq("catalog", catalogId) + .maybeSingle(); + + if (error) { + console.error("Error fetching account_catalogs:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts b/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts new file mode 100644 index 00000000..5e09d6dc --- /dev/null +++ b/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectCatalogSongTitles } from "../selectCatalogSongTitles"; +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"]) builder[m] = vi.fn().mockReturnValue(builder); + builder.then = resolve => resolve(result); + vi.mocked(supabase.from).mockReturnValue(builder as never); + return builder; +} + +describe("selectCatalogSongTitles", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns isrc + title pairs for the catalog's songs", async () => { + const builder = mockBuilder({ + data: [ + { songs: { isrc: "ISRC1", name: "Song One" } }, + { songs: { isrc: "ISRC2", name: null } }, + ], + error: null, + }); + + const result = await selectCatalogSongTitles("cat_1"); + + expect(supabase.from).toHaveBeenCalledWith("catalog_songs"); + expect(builder.eq).toHaveBeenCalledWith("catalog", "cat_1"); + expect(result).toEqual([ + { isrc: "ISRC1", title: "Song One" }, + { isrc: "ISRC2", title: null }, + ]); + }); + + it("returns [] on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + mockBuilder({ data: null, error: { message: "boom" } }); + + expect(await selectCatalogSongTitles("cat_1")).toEqual([]); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/catalog_songs/selectCatalogSongTitles.ts b/lib/supabase/catalog_songs/selectCatalogSongTitles.ts new file mode 100644 index 00000000..9422348a --- /dev/null +++ b/lib/supabase/catalog_songs/selectCatalogSongTitles.ts @@ -0,0 +1,25 @@ +import supabase from "../serverClient"; + +export type CatalogSongTitle = { isrc: string; title: string | null }; + +/** + * Select the ISRC + title of every song in a catalog. Lighter than + * selectCatalogSongsWithArtists: no artists join, no pagination — used by + * reads that only need the tracklist (e.g. catalog measurements). + * + * @param catalogId - The catalog to list songs for + * @returns ISRC + title pairs, or [] if none exist or on error + */ +export async function selectCatalogSongTitles(catalogId: string): Promise { + const { data, error } = await supabase + .from("catalog_songs") + .select("songs!inner (isrc, name)") + .eq("catalog", catalogId); + + if (error) { + console.error("Error fetching catalog_songs:", error); + return []; + } + + return (data ?? []).map(row => ({ isrc: row.songs.isrc, title: row.songs.name })); +} diff --git a/lib/supabase/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts b/lib/supabase/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts index 77898c27..e4656184 100644 --- a/lib/supabase/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts +++ b/lib/supabase/playcount_snapshots/__tests__/selectPlaycountSnapshots.test.ts @@ -38,6 +38,16 @@ describe("selectPlaycountSnapshots", () => { expect(result).toEqual(rows); }); + it("filters by catalog", async () => { + const rows = [{ id: "snap_2", catalog: "cat_1" }]; + const builder = mockBuilder({ data: rows, error: null }); + + const result = await selectPlaycountSnapshots({ catalog: "cat_1" }); + + expect(builder.eq).toHaveBeenCalledWith("catalog", "cat_1"); + expect(result).toEqual(rows); + }); + it("returns [] on error", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); mockBuilder({ data: null, error: { message: "boom" } }); diff --git a/lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts b/lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts index f3a26055..481b12c7 100644 --- a/lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts +++ b/lib/supabase/playcount_snapshots/selectPlaycountSnapshots.ts @@ -7,6 +7,7 @@ import { Tables } from "@/types/database.types"; * * @param params.id - Optional snapshot id filter * @param params.account - Optional account filter + * @param params.catalog - Optional catalog filter (runs materialized into a catalog) * @param params.createdAfter - Optional inclusive created_at lower bound (ISO) * @param params.schedule - Optional schedule filter ("once" | "monthly") * @returns Matching rows newest-first, or [] if none exist or on error @@ -14,11 +15,13 @@ import { Tables } from "@/types/database.types"; export async function selectPlaycountSnapshots({ id, account, + catalog, createdAfter, schedule, }: { id?: string; account?: string; + catalog?: string; createdAfter?: string; schedule?: string; }): Promise[]> { @@ -29,6 +32,7 @@ export async function selectPlaycountSnapshots({ if (id) query = query.eq("id", id); if (account) query = query.eq("account", account); + if (catalog) query = query.eq("catalog", catalog); if (schedule) query = query.eq("schedule", schedule); if (createdAfter) query = query.gte("created_at", createdAfter);