diff --git a/app/api/catalogs/[catalogId]/measurements/route.ts b/app/api/catalogs/[catalogId]/measurements/route.ts new file mode 100644 index 00000000..4108d243 --- /dev/null +++ b/app/api/catalogs/[catalogId]/measurements/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getCatalogMeasurementsHandler } from "@/lib/catalog/getCatalogMeasurementsHandler"; + +/** + * OPTIONS /api/catalogs/{catalogId}/measurements — CORS preflight. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/catalogs/{catalogId}/measurements — one page of a catalog's + * latest per-song play counts plus whole-scope aggregates and the derived + * valuation band. + * + * @param request - The request object. + * @param options - Route options containing params. + * @param options.params - Route params containing the catalogId. + * @returns A NextResponse with measurements, pagination, and aggregates. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ catalogId: string }> }, +) { + const { catalogId } = await options.params; + return getCatalogMeasurementsHandler(request, catalogId); +} diff --git a/app/api/catalogs/measurements/route.ts b/app/api/catalogs/measurements/route.ts deleted file mode 100644 index 9ee1dfe9..00000000 --- a/app/api/catalogs/measurements/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -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__/getCatalogMeasurementsHandler.test.ts b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts index 17149e89..369f1de6 100644 --- a/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts +++ b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts @@ -4,10 +4,9 @@ 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"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -16,137 +15,199 @@ 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/selectCatalogMeasurementsAggregate", () => ({ + selectCatalogMeasurementsAggregate: vi.fn(), })); -vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ - selectSongMeasurements: vi.fn(), +vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsPage", () => ({ + selectCatalogMeasurementsPage: vi.fn(), })); const accountId = "550e8400-e29b-41d4-a716-446655440000"; const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; +const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; const makeRequest = () => - new NextRequest(`http://localhost/api/catalogs/measurements?catalogId=${catalogId}`); + new NextRequest(`http://localhost/api/catalogs/${catalogId}/measurements`); -const okAuth = () => - vi.mocked(validateAuthContext).mockResolvedValue({ +const okQuery = (query: Record = { accountId, catalogId, page: 1, limit: 50 }) => + vi.mocked(validateGetCatalogMeasurementsQuery).mockResolvedValue({ accountId, - orgId: null, - authToken: "t", + ...query, } as never); -const okQuery = () => vi.mocked(validateGetCatalogMeasurementsQuery).mockReturnValue({ catalogId }); +const okCatalog = () => + vi.mocked(selectAccountCatalog).mockResolvedValue({ + account: accountId, + catalog: catalogId, + } as never); + +const pageRows = [ + { 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" }, +]; 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); + it("short-circuits with the validator error (auth + params live in the validator)", async () => { + const err = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateGetCatalogMeasurementsQuery).mockResolvedValue(err); - const res = await getCatalogMeasurementsHandler(makeRequest()); + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); 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()); + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); expect(res.status).toBe(404); expect(selectAccountCatalog).toHaveBeenCalledWith({ accountId, catalogId }); - expect(selectCatalogSongTitles).not.toHaveBeenCalled(); + expect(selectCatalogMeasurementsAggregate).not.toHaveBeenCalled(); }); - it("returns latest per-ISRC measurements + the derived valuation band", async () => { + it("returns one page of measurements + whole-scope aggregates and the derived 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); + okCatalog(); + // whole-scope aggregate is bigger than the returned page + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 120, + totalStreams: 250, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue(pageRows); // 10 years of catalog age vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); - const res = await getCatalogMeasurementsHandler(makeRequest()); + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); const body = await res.json(); expect(res.status).toBe(200); - expect(selectSongMeasurements).toHaveBeenCalledWith({ - songs: ["ISRC1", "ISRC2"], - platform: "spotify", - metric: "platform_displayed_play_count", + expect(validateGetCatalogMeasurementsQuery).toHaveBeenCalledWith( + expect.any(NextRequest), + catalogId, + ); + expect(selectCatalogMeasurementsAggregate).toHaveBeenCalledWith({ + catalogId, + artistAccountId: undefined, + }); + expect(selectCatalogMeasurementsPage).toHaveBeenCalledWith({ + catalogId, + artistAccountId: undefined, + page: 1, + limit: 50, }); 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.measurements).toEqual(pageRows); + expect(body.pagination).toEqual({ + total_count: 120, + page: 1, + limit: 50, + total_pages: 3, + }); + expect(body.measured_song_count).toBe(120); expect(body.total_streams).toBe(250); expect(body.catalog_age_years).toBe(10); + // whole-catalog read: the echo field states no artist filter was applied + expect(body.artist_account_id).toBeNull(); // 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([]); + it("scopes the read to the artist and echoes the applied filter", async () => { + okQuery({ catalogId, artist_account_id: artistAccountId, page: 2, limit: 10 }); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 11, + totalStreams: 200, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([pageRows[0]]); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); + + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(selectCatalogMeasurementsAggregate).toHaveBeenCalledWith({ + catalogId, + artistAccountId, + }); + expect(selectCatalogMeasurementsPage).toHaveBeenCalledWith({ + catalogId, + artistAccountId, + page: 2, + limit: 10, + }); + expect(body.measurements).toEqual([pageRows[0]]); + expect(body.pagination).toEqual({ total_count: 11, page: 2, limit: 10, total_pages: 2 }); + expect(body.measured_song_count).toBe(11); + expect(body.total_streams).toBe(200); + expect(body.artist_account_id).toBe(artistAccountId); + }); + + it("returns an empty page with a zero band when nothing is measured in scope", async () => { + okQuery({ catalogId, artist_account_id: artistAccountId, page: 1, limit: 50 }); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 0, + totalStreams: 0, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([]); vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null); - const res = await getCatalogMeasurementsHandler(makeRequest()); + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); const body = await res.json(); expect(res.status).toBe(200); expect(body.measurements).toEqual([]); + expect(body.pagination).toEqual({ total_count: 0, page: 1, limit: 50, total_pages: 0 }); + expect(body.measured_song_count).toBe(0); expect(body.total_streams).toBe(0); expect(body.valuation).toEqual({ low: 0, mid: 0, high: 0 }); + expect(body.artist_account_id).toBe(artistAccountId); + }); + + it("returns 500 when the aggregate read fails", async () => { + okQuery(); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue(null); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue(pageRows); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null); + + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); + + expect(res.status).toBe(500); + }); + + it("returns 500 when the page read fails", async () => { + okQuery(); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 1, + totalStreams: 1, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue(null); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue(null); + + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); + + expect(res.status).toBe(500); }); 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()); + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); expect(res.status).toBe(500); consoleError.mockRestore(); diff --git a/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts b/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts deleted file mode 100644 index 6ff0b350..00000000 --- a/lib/catalog/__tests__/latestMeasurementsPerIsrc.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 index 4748de47..05669c10 100644 --- a/lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts +++ b/lib/catalog/__tests__/validateGetCatalogMeasurementsQuery.test.ts @@ -1,22 +1,47 @@ -import { describe, it, expect, vi } from "vitest"; -import { NextResponse } from "next/server"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; import { validateGetCatalogMeasurementsQuery } from "../validateGetCatalogMeasurementsQuery"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); + const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; +const accountId = "fb678396-a68f-4294-ae50-b8cacf9ce77b"; + +const makeRequest = (query = "") => + new NextRequest(`http://localhost/api/catalogs/${catalogId}/measurements${query}`); + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId, + orgId: null, + authToken: "t", + } as never); +}); describe("validateGetCatalogMeasurementsQuery", () => { - it("returns the validated query for a valid catalogId", () => { - const result = validateGetCatalogMeasurementsQuery(new URLSearchParams({ catalogId })); + it("returns the auth error before validating params when credentials are missing", async () => { + const authErr = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr as never); - expect(result).toEqual({ catalogId }); + const result = await validateGetCatalogMeasurementsQuery(makeRequest(), "not-a-uuid"); + + expect(result).toBe(authErr); }); - it("returns 400 when catalogId is missing", async () => { - const result = validateGetCatalogMeasurementsQuery(new URLSearchParams()); + it("returns the validated request with the caller accountId for a valid path catalogId", async () => { + const result = await validateGetCatalogMeasurementsQuery(makeRequest(), catalogId); + + expect(result).toEqual({ accountId, catalogId, page: 1, limit: 50 }); + }); + + it("returns 400 when the path catalogId is not a uuid", async () => { + const result = await validateGetCatalogMeasurementsQuery(makeRequest(), "not-a-uuid"); expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(400); @@ -24,12 +49,71 @@ describe("validateGetCatalogMeasurementsQuery", () => { expect(body.status).toBe("error"); }); - it("returns 400 when catalogId is not a uuid", () => { - const result = validateGetCatalogMeasurementsQuery( - new URLSearchParams({ catalogId: "not-a-uuid" }), + it("accepts an optional artist_account_id uuid", async () => { + const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; + const result = await validateGetCatalogMeasurementsQuery( + makeRequest(`?artist_account_id=${artistAccountId}`), + catalogId, + ); + + expect(result).toEqual({ + accountId, + catalogId, + artist_account_id: artistAccountId, + page: 1, + limit: 50, + }); + }); + + it("returns 400 when artist_account_id is malformed", async () => { + const result = await validateGetCatalogMeasurementsQuery( + makeRequest("?artist_account_id=not-a-uuid"), + catalogId, ); expect(result).toBeInstanceOf(NextResponse); expect((result as NextResponse).status).toBe(400); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + + it("accepts explicit page and limit", async () => { + const result = await validateGetCatalogMeasurementsQuery( + makeRequest("?page=3&limit=100"), + catalogId, + ); + + expect(result).toEqual({ accountId, catalogId, page: 3, limit: 100 }); + }); + + it("returns 400 when page is not a positive integer", async () => { + for (const page of ["0", "-1", "abc", "1.5"]) { + const result = await validateGetCatalogMeasurementsQuery( + makeRequest(`?page=${page}`), + catalogId, + ); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + } + }); + + it("returns 400 when limit is out of range", async () => { + for (const limit of ["0", "101", "abc"]) { + const result = await validateGetCatalogMeasurementsQuery( + makeRequest(`?limit=${limit}`), + catalogId, + ); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + } + }); + + it("ignores a catalogId smuggled into the query string in favor of the path", async () => { + const result = await validateGetCatalogMeasurementsQuery( + makeRequest("?catalogId=b1814076-8e19-4a77-9dea-2ec150e26aaa"), + catalogId, + ); + + expect(result).toEqual({ accountId, catalogId, page: 1, limit: 50 }); }); }); diff --git a/lib/catalog/getCatalogMeasurementsHandler.ts b/lib/catalog/getCatalogMeasurementsHandler.ts index dcd8dfe0..ab1b1498 100644 --- a/lib/catalog/getCatalogMeasurementsHandler.ts +++ b/lib/catalog/getCatalogMeasurementsHandler.ts @@ -1,69 +1,74 @@ 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"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; /** - * GET /api/catalogs/measurements?catalogId= + * GET /api/catalogs/{catalogId}/measurements?artist_account_id=&page=&limit= * - * 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. + * One page of the latest Spotify play counts per song (ISRC) in a catalog + * plus whole-scope aggregates: measured_song_count, total_streams and a + * valuation band derived at read time with the same model as the marketing + * valuation card. The aggregates are computed in a single SQL aggregate over + * the entire scope — no row cap — regardless of the requested page. Auth and + * input validation both live in validateGetCatalogMeasurementsQuery (SRP). + * Optionally scoped to one artist's songs (catalog_songs ∩ song_artists) via + * artist_account_id; the applied filter is echoed back so clients can verify + * the response scope. 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 }` + * @param catalogIdParam - The catalogId path segment + * @returns `{ status, measurements, pagination, measured_song_count, total_streams, valuation, artist_account_id, catalog_age_years }` */ -export async function getCatalogMeasurementsHandler(request: NextRequest): Promise { +export async function getCatalogMeasurementsHandler( + request: NextRequest, + catalogIdParam: string, +): Promise { try { - const { searchParams } = new URL(request.url); - const validated = validateGetCatalogMeasurementsQuery(searchParams); + const validated = await validateGetCatalogMeasurementsQuery(request, catalogIdParam); if (validated instanceof NextResponse) { return validated; } - - const authResult = await validateAuthContext(request); - if (authResult instanceof NextResponse) { - return authResult; - } - const { accountId } = authResult; - const { catalogId } = validated; + const { accountId, catalogId, artist_account_id: artistAccountId, page, limit } = 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([]), + const [aggregate, measurements, earliestReleaseDate] = await Promise.all([ + selectCatalogMeasurementsAggregate({ catalogId, artistAccountId }), + selectCatalogMeasurementsPage({ catalogId, artistAccountId, page, limit }), getCatalogEarliestReleaseDate(catalogId), ]); + if (!aggregate || !measurements) { + return errorResponse("Internal server error", 500); + } - const { measurements, totalStreams } = latestMeasurementsPerIsrc(rows, titles); const { valuation, catalogAgeYears } = computeValuationBand({ - totalStreams, + totalStreams: aggregate.totalStreams, earliestReleaseDate, }); return successResponse({ measurements, + pagination: { + total_count: aggregate.measuredSongCount, + page, + limit, + total_pages: Math.ceil(aggregate.measuredSongCount / limit), + }, + measured_song_count: aggregate.measuredSongCount, + total_streams: aggregate.totalStreams, valuation, - total_streams: totalStreams, + artist_account_id: artistAccountId ?? null, catalog_age_years: catalogAgeYears, }); } catch (error) { diff --git a/lib/catalog/latestMeasurementsPerIsrc.ts b/lib/catalog/latestMeasurementsPerIsrc.ts deleted file mode 100644 index 3774bf38..00000000 --- a/lib/catalog/latestMeasurementsPerIsrc.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 index 0fc7801c..c13d4f84 100644 --- a/lib/catalog/validateGetCatalogMeasurementsQuery.ts +++ b/lib/catalog/validateGetCatalogMeasurementsQuery.ts @@ -1,27 +1,61 @@ -import { NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; import { z } from "zod"; export const getCatalogMeasurementsQuerySchema = z.object({ catalogId: z .string({ message: "catalogId parameter is required" }) .uuid("catalogId must be a valid UUID"), + artist_account_id: z.string().uuid("artist_account_id must be a valid UUID").optional(), + page: z + .string() + .optional() + .default("1") + .transform(val => Number(val)) + .pipe(z.number().int("page must be a positive integer").positive("page must be positive")), + limit: z + .string() + .optional() + .default("50") + .transform(val => Number(val)) + .pipe( + z + .number() + .int("limit must be an integer") + .min(1, "limit must be at least 1") + .max(100, "limit must be at most 100"), + ), }); -export type GetCatalogMeasurementsQuery = z.infer; +export type GetCatalogMeasurementsQuery = z.infer & { + accountId: string; +}; /** - * Validates query parameters for GET /api/catalogs/measurements. + * Validates GET /api/catalogs/{catalogId}/measurements — auth (Privy bearer + * or x-api-key, resolved to the caller's accountId), the catalogId path + * segment (uuid), and the optional query modifiers (artist_account_id, page, + * limit). Auth runs first, per the validator convention of the measurements + * family. The path id always wins — a catalogId smuggled into the query + * string is ignored. * - * @param searchParams - The URL search parameters to validate. - * @returns A NextResponse with an error if validation fails, or the validated query. + * @param request - The incoming HTTP request. + * @param catalogId - The catalogId path segment. + * @returns A NextResponse with an error if validation fails, or the validated request. */ -export function validateGetCatalogMeasurementsQuery( - searchParams: URLSearchParams, -): NextResponse | GetCatalogMeasurementsQuery { - const result = getCatalogMeasurementsQuerySchema.safeParse( - Object.fromEntries(searchParams.entries()), - ); +export async function validateGetCatalogMeasurementsQuery( + request: NextRequest, + catalogId: string, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const result = getCatalogMeasurementsQuerySchema.safeParse({ + ...Object.fromEntries(searchParams.entries()), + catalogId, + }); if (!result.success) { const firstError = result.error.issues[0]; @@ -35,5 +69,5 @@ export function validateGetCatalogMeasurementsQuery( ); } - return result.data; + return { ...result.data, accountId: authResult.accountId }; } diff --git a/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts b/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts deleted file mode 100644 index 5e09d6dc..00000000 --- a/lib/supabase/catalog_songs/__tests__/selectCatalogSongTitles.test.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 9422348a..00000000 --- a/lib/supabase/catalog_songs/selectCatalogSongTitles.ts +++ /dev/null @@ -1,25 +0,0 @@ -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/song_measurements/__tests__/selectCatalogMeasurementsAggregate.test.ts b/lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsAggregate.test.ts new file mode 100644 index 00000000..377eb0e4 --- /dev/null +++ b/lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsAggregate.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectCatalogMeasurementsAggregate } from "../selectCatalogMeasurementsAggregate"; +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => ({ + default: { rpc: vi.fn() }, +})); + +const catalogId = "7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78"; +const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; + +describe("selectCatalogMeasurementsAggregate", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the whole-scope aggregate for a catalog", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ + data: [{ measured_song_count: 2679, total_streams: 16308837441 }], + error: null, + } as never); + + const result = await selectCatalogMeasurementsAggregate({ catalogId }); + + expect(supabase.rpc).toHaveBeenCalledWith("get_catalog_measurements_aggregate", { + p_catalog: catalogId, + }); + expect(result).toEqual({ measuredSongCount: 2679, totalStreams: 16308837441 }); + }); + + it("passes the artist filter through when scoped", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ + data: [{ measured_song_count: 322, total_streams: 1799402184 }], + error: null, + } as never); + + const result = await selectCatalogMeasurementsAggregate({ catalogId, artistAccountId }); + + expect(supabase.rpc).toHaveBeenCalledWith("get_catalog_measurements_aggregate", { + p_catalog: catalogId, + p_artist: artistAccountId, + }); + expect(result).toEqual({ measuredSongCount: 322, totalStreams: 1799402184 }); + }); + + it("coerces numeric strings from PostgREST", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ + data: [{ measured_song_count: 40, total_streams: "21325663" }], + error: null, + } as never); + + const result = await selectCatalogMeasurementsAggregate({ catalogId }); + + expect(result).toEqual({ measuredSongCount: 40, totalStreams: 21325663 }); + }); + + it("returns a zero aggregate when the rpc yields no row", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: [], error: null } as never); + + expect(await selectCatalogMeasurementsAggregate({ catalogId })).toEqual({ + measuredSongCount: 0, + totalStreams: 0, + }); + }); + + it("returns null on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(supabase.rpc).mockResolvedValue({ + data: null, + error: { message: "boom" }, + } as never); + + expect(await selectCatalogMeasurementsAggregate({ catalogId })).toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts b/lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts new file mode 100644 index 00000000..0cf70d44 --- /dev/null +++ b/lib/supabase/song_measurements/__tests__/selectCatalogMeasurementsPage.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectCatalogMeasurementsPage } from "../selectCatalogMeasurementsPage"; +import supabase from "../../serverClient"; + +vi.mock("../../serverClient", () => ({ + default: { rpc: vi.fn() }, +})); + +const catalogId = "7d3e5c35-1fac-4b88-aa90-e2e4a52dfe78"; +const artistAccountId = "b1814076-8e19-4a77-9dea-2ec150e26aaa"; + +const row = { + isrc: "USUYG1075006", + title: "Suavemente", + playcount: 717012221, + measured_at: "2026-07-06T00:00:00+00:00", +}; + +describe("selectCatalogMeasurementsPage", () => { + beforeEach(() => vi.clearAllMocks()); + + it("reads one page with limit/offset derived from page/limit", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: [row], error: null } as never); + + const result = await selectCatalogMeasurementsPage({ catalogId, page: 3, limit: 50 }); + + expect(supabase.rpc).toHaveBeenCalledWith("get_catalog_measurements_page", { + p_catalog: catalogId, + p_limit: 50, + p_offset: 100, + }); + expect(result).toEqual([row]); + }); + + it("passes the artist filter through when scoped", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: [row], error: null } as never); + + await selectCatalogMeasurementsPage({ catalogId, artistAccountId, page: 1, limit: 20 }); + + expect(supabase.rpc).toHaveBeenCalledWith("get_catalog_measurements_page", { + p_catalog: catalogId, + p_artist: artistAccountId, + p_limit: 20, + p_offset: 0, + }); + }); + + it("returns [] when the page is past the end", async () => { + vi.mocked(supabase.rpc).mockResolvedValue({ data: [], error: null } as never); + + expect(await selectCatalogMeasurementsPage({ catalogId, page: 99, limit: 50 })).toEqual([]); + }); + + it("returns null on error", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(supabase.rpc).mockResolvedValue({ + data: null, + error: { message: "boom" }, + } as never); + + expect(await selectCatalogMeasurementsPage({ catalogId, page: 1, limit: 50 })).toBeNull(); + consoleError.mockRestore(); + }); +}); diff --git a/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts b/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts new file mode 100644 index 00000000..fe8affdf --- /dev/null +++ b/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate.ts @@ -0,0 +1,41 @@ +import supabase from "../serverClient"; + +export type CatalogMeasurementsAggregate = { + measuredSongCount: number; + totalStreams: number; +}; + +/** + * Whole-scope aggregate for a catalog's latest measurements via the + * get_catalog_measurements_aggregate RPC: count + total streams over the + * latest capture per ISRC, computed in a single SQL aggregate — no row cap, + * regardless of catalog size. Optionally scoped to one artist account + * (catalog_songs ∩ song_artists). + * + * @param params.catalogId - The catalog to aggregate measurements for + * @param params.artistAccountId - Optional artist account to scope the read to + * @returns The aggregate (zeros when nothing is measured), or null on error + */ +export async function selectCatalogMeasurementsAggregate({ + catalogId, + artistAccountId, +}: { + catalogId: string; + artistAccountId?: string; +}): Promise { + const { data, error } = await supabase.rpc("get_catalog_measurements_aggregate", { + p_catalog: catalogId, + ...(artistAccountId ? { p_artist: artistAccountId } : {}), + }); + + if (error) { + console.error("Error aggregating catalog measurements:", error); + return null; + } + + const row = data?.[0]; + return { + measuredSongCount: Number(row?.measured_song_count ?? 0), + totalStreams: Number(row?.total_streams ?? 0), + }; +} diff --git a/lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts b/lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts new file mode 100644 index 00000000..46820cf6 --- /dev/null +++ b/lib/supabase/song_measurements/selectCatalogMeasurementsPage.ts @@ -0,0 +1,47 @@ +import supabase from "../serverClient"; + +export type CatalogMeasurementRow = { + isrc: string; + title: string | null; + playcount: number; + measured_at: string; +}; + +/** + * One page of a catalog's latest-per-ISRC measurements via the + * get_catalog_measurements_page RPC, sorted by play count descending. + * Optionally scoped to one artist account (catalog_songs ∩ song_artists). + * Pagination only windows the rows — pair with + * selectCatalogMeasurementsAggregate for whole-scope totals. + * + * @param params.catalogId - The catalog to read measurements for + * @param params.artistAccountId - Optional artist account to scope the read to + * @param params.page - 1-based page number + * @param params.limit - Rows per page + * @returns The page's measurement rows ([] past the end), or null on error + */ +export async function selectCatalogMeasurementsPage({ + catalogId, + artistAccountId, + page, + limit, +}: { + catalogId: string; + artistAccountId?: string; + page: number; + limit: number; +}): Promise { + const { data, error } = await supabase.rpc("get_catalog_measurements_page", { + p_catalog: catalogId, + ...(artistAccountId ? { p_artist: artistAccountId } : {}), + p_limit: limit, + p_offset: (page - 1) * limit, + }); + + if (error) { + console.error("Error fetching catalog measurements page:", error); + return null; + } + + return data ?? []; +} diff --git a/types/database.types.ts b/types/database.types.ts index 45da666d..5cdec74e 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -3829,6 +3829,27 @@ export type Database = { Returns: undefined; }; extract_domain: { Args: { email: string }; Returns: string }; + get_catalog_measurements_aggregate: { + Args: { p_catalog: string; p_artist?: string }; + Returns: { + measured_song_count: number; + total_streams: number; + }[]; + }; + get_catalog_measurements_page: { + Args: { + p_catalog: string; + p_artist?: string; + p_limit?: number; + p_offset?: number; + }; + Returns: { + isrc: string; + title: string | null; + playcount: number; + measured_at: string; + }[]; + }; get_account_invitations: { Args: { account_slug: string }; Returns: {