diff --git a/app/api/catalogs/route.ts b/app/api/catalogs/route.ts new file mode 100644 index 000000000..5ab6c6692 --- /dev/null +++ b/app/api/catalogs/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { createCatalogHandler } from "@/lib/catalog/createCatalogHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST handler for creating a catalog (optionally materialized from a + * valuation snapshot). + * + * @param request - The request object containing the catalog body. + * @returns A NextResponse with the created catalog. + */ +export async function POST(request: NextRequest) { + return createCatalogHandler(request); +} diff --git a/lib/catalog/__tests__/createCatalogHandler.test.ts b/lib/catalog/__tests__/createCatalogHandler.test.ts new file mode 100644 index 000000000..5e2b1ebf0 --- /dev/null +++ b/lib/catalog/__tests__/createCatalogHandler.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { createCatalogHandler } from "../createCatalogHandler"; +import { validateCreateCatalogBody } from "../validateCreateCatalogBody"; +import { createSnapshotCatalog } from "../createSnapshotCatalog"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("../validateCreateCatalogBody", () => ({ validateCreateCatalogBody: vi.fn() })); +vi.mock("../createSnapshotCatalog", () => ({ createSnapshotCatalog: vi.fn() })); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); +vi.mock("@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots", () => ({ + selectPlaycountSnapshots: vi.fn(), +})); +vi.mock("@/lib/supabase/catalogs/selectCatalogById", () => ({ selectCatalogById: vi.fn() })); +vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() })); +vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({ + insertAccountCatalog: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const otherAccountId = "550e8400-e29b-41d4-a716-446655440999"; +const snapshotId = "11111111-2222-3333-4444-555555555555"; +const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + +const catalog = { + id: catalogId, + name: "Bad Bunny — Catalog", + created_at: "2026-06-18T00:00:00Z", + updated_at: "2026-06-18T00:00:00Z", +}; + +const makeRequest = () => new NextRequest("http://localhost/api/catalogs", { method: "POST" }); + +const okAuth = () => + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null, authToken: "t" }); + +describe("createCatalogHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits with the validator error and never authenticates", async () => { + const err = NextResponse.json({ status: "error" }, { status: 400 }); + vi.mocked(validateCreateCatalogBody).mockReturnValue(err); + + const res = await createCatalogHandler(makeRequest()); + + expect(res).toBe(err); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("short-circuits with the auth error when unauthenticated", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" }); + const authErr = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authErr); + + const res = await createCatalogHandler(makeRequest()); + + expect(res).toBe(authErr); + expect(insertCatalog).not.toHaveBeenCalled(); + }); + + it("creates an empty catalog from a name-only body and links the account", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "Bad Bunny — Catalog" }); + okAuth(); + vi.mocked(insertCatalog).mockResolvedValue(catalog); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny — Catalog"); + expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId }); + expect(body).toEqual({ status: "success", catalog, songs_added: 0 }); + expect(selectPlaycountSnapshots).not.toHaveBeenCalled(); + }); + + it("materializes a catalog from an owned, not-yet-claimed snapshot", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: accountId, catalog: null, isrcs: ["A", "B"] } as never, + ]); + vi.mocked(createSnapshotCatalog).mockResolvedValue({ catalog, songsAdded: 2 }); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(createSnapshotCatalog).toHaveBeenCalledWith({ + accountId, + snapshot: expect.objectContaining({ id: snapshotId }), + name: "Bad Bunny — Catalog", + }); + expect(body).toEqual({ status: "success", catalog, songs_added: 2 }); + }); + + it("returns 404 when the snapshot does not exist", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([]); + + const res = await createCatalogHandler(makeRequest()); + + expect(res.status).toBe(404); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + }); + + it("returns 403 when the snapshot belongs to a different account", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: otherAccountId, catalog: null, isrcs: ["A"] } as never, + ]); + + const res = await createCatalogHandler(makeRequest()); + + expect(res.status).toBe(403); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + }); + + it("is idempotent: returns the existing catalog when the snapshot is already claimed", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ snapshot: snapshotId }); + okAuth(); + vi.mocked(selectPlaycountSnapshots).mockResolvedValue([ + { id: snapshotId, account: accountId, catalog: catalogId, isrcs: ["A", "B"] } as never, + ]); + vi.mocked(selectCatalogById).mockResolvedValue(catalog); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(selectCatalogById).toHaveBeenCalledWith(catalogId); + expect(createSnapshotCatalog).not.toHaveBeenCalled(); + expect(body).toEqual({ status: "success", catalog, songs_added: 0 }); + }); + + it("returns a generic 500 without leaking the underlying error", async () => { + vi.mocked(validateCreateCatalogBody).mockReturnValue({ name: "X" }); + okAuth(); + vi.mocked(insertCatalog).mockRejectedValue(new Error("db down at 10.0.0.1:5432")); + + const res = await createCatalogHandler(makeRequest()); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body.status).toBe("error"); + expect(JSON.stringify(body)).not.toContain("10.0.0.1"); + }); +}); diff --git a/lib/catalog/__tests__/createSnapshotCatalog.test.ts b/lib/catalog/__tests__/createSnapshotCatalog.test.ts new file mode 100644 index 000000000..9ee7a3ede --- /dev/null +++ b/lib/catalog/__tests__/createSnapshotCatalog.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSnapshotCatalog } from "../createSnapshotCatalog"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; +import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs"; +import { updatePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +vi.mock("@/lib/supabase/catalogs/insertCatalog", () => ({ insertCatalog: vi.fn() })); +vi.mock("@/lib/supabase/account_catalogs/insertAccountCatalog", () => ({ + insertAccountCatalog: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_songs/insertCatalogSongs", () => ({ insertCatalogSongs: vi.fn() })); +vi.mock("@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot", () => ({ + updatePlaycountSnapshot: vi.fn(), +})); +vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ + selectSongMeasurements: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const snapshotId = "11111111-2222-3333-4444-555555555555"; +const catalogId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; +const catalog = { id: catalogId, name: "Bad Bunny Catalog", created_at: "t", updated_at: "t" }; +// A real valuation snapshot is scoped by album_ids, so its own `isrcs` column is null — +// the measured ISRCs live in song_measurements (sourced via selectSongMeasurements). +const snapshot = { id: snapshotId, account: accountId, catalog: null, isrcs: null } as never; +const measurement = (song: string) => ({ song }) as never; + +describe("createSnapshotCatalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(insertCatalog).mockResolvedValue(catalog); + }); + + it("sources measured ISRCs from song_measurements (by snapshot) and adds them as catalog songs", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([ + measurement("ISRC_A"), + measurement("ISRC_B"), + measurement("ISRC_C"), + ]); + + const result = await createSnapshotCatalog({ accountId, snapshot, name: "Bad Bunny Catalog" }); + + expect(insertCatalog).toHaveBeenCalledWith("Bad Bunny Catalog"); + expect(insertAccountCatalog).toHaveBeenCalledWith({ account: accountId, catalog: catalogId }); + // ISRCs come from measurements by snapshot, NOT snapshot.isrcs (null here) + expect(selectSongMeasurements).toHaveBeenCalledWith({ snapshot: snapshotId }); + expect(insertCatalogSongs).toHaveBeenCalledWith([ + { catalog: catalogId, song: "ISRC_A" }, + { catalog: catalogId, song: "ISRC_B" }, + { catalog: catalogId, song: "ISRC_C" }, + ]); + expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId }); + expect(result).toEqual({ catalog, songsAdded: 3 }); + }); + + it("dedupes ISRCs across multiple measurement rows per track", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([ + measurement("ISRC_A"), + measurement("ISRC_A"), + measurement("ISRC_B"), + ]); + + const result = await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalogSongs).toHaveBeenCalledWith([ + { catalog: catalogId, song: "ISRC_A" }, + { catalog: catalogId, song: "ISRC_B" }, + ]); + expect(result).toEqual({ catalog, songsAdded: 2 }); + }); + + it("adds no songs when the snapshot has no measurements", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([]); + + const result = await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalogSongs).not.toHaveBeenCalled(); + expect(updatePlaycountSnapshot).toHaveBeenCalledWith(snapshotId, { catalog: catalogId }); + expect(result).toEqual({ catalog, songsAdded: 0 }); + }); + + it("falls back to a default name when none is supplied", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([]); + + await createSnapshotCatalog({ accountId, snapshot }); + + expect(insertCatalog).toHaveBeenCalledWith("Valuation Catalog"); + }); +}); diff --git a/lib/catalog/__tests__/validateCreateCatalogBody.test.ts b/lib/catalog/__tests__/validateCreateCatalogBody.test.ts new file mode 100644 index 000000000..670895f6a --- /dev/null +++ b/lib/catalog/__tests__/validateCreateCatalogBody.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; + +import { validateCreateCatalogBody } from "../validateCreateCatalogBody"; + +const snapshotId = "550e8400-e29b-41d4-a716-446655440000"; + +describe("validateCreateCatalogBody", () => { + it("accepts a name-only body", () => { + const result = validateCreateCatalogBody({ name: "My Catalog" }); + expect(result).toEqual({ name: "My Catalog" }); + }); + + it("accepts a snapshot-only body", () => { + const result = validateCreateCatalogBody({ snapshot: snapshotId }); + expect(result).toEqual({ snapshot: snapshotId }); + }); + + it("accepts name and snapshot together", () => { + const result = validateCreateCatalogBody({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + expect(result).toEqual({ + name: "Bad Bunny — Catalog", + snapshot: snapshotId, + }); + }); + + it("rejects an empty body (neither name nor snapshot) with 400", async () => { + const result = validateCreateCatalogBody({}); + expect(result).toBeInstanceOf(NextResponse); + const res = result as NextResponse; + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.status).toBe("error"); + }); + + it("rejects a non-uuid snapshot with 400", async () => { + const result = validateCreateCatalogBody({ snapshot: "not-a-uuid" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("rejects a null/non-object body with 400", async () => { + const result = validateCreateCatalogBody(null); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/catalog/createCatalogHandler.ts b/lib/catalog/createCatalogHandler.ts new file mode 100644 index 000000000..583746971 --- /dev/null +++ b/lib/catalog/createCatalogHandler.ts @@ -0,0 +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 { validateCreateCatalogBody } from "./validateCreateCatalogBody"; +import { createSnapshotCatalog } from "./createSnapshotCatalog"; +import { selectPlaycountSnapshots } from "@/lib/supabase/playcount_snapshots/selectPlaycountSnapshots"; +import { selectCatalogById } from "@/lib/supabase/catalogs/selectCatalogById"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; + +const DEFAULT_CATALOG_NAME = "Valuation Catalog"; + +/** + * POST /api/catalogs + * + * Creates a catalog owned by the authenticated account. The owning account is + * resolved from credentials (Privy bearer or x-api-key), never from the body. + * + * With `snapshot`, materializes the catalog from a completed valuation snapshot: + * the snapshot must be owned by the caller, and re-claiming the same snapshot is + * idempotent (returns the catalog already created for that run). + * + * @param request - The request object + * @returns A NextResponse with `{ status, catalog, songs_added }` + */ +export async function createCatalogHandler(request: NextRequest): Promise { + try { + const body = await request.json().catch(() => null); + + const validated = validateCreateCatalogBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) { + return authResult; + } + const { accountId } = authResult; + + if (!validated.snapshot) { + const catalog = await insertCatalog(validated.name ?? DEFAULT_CATALOG_NAME); + await insertAccountCatalog({ account: accountId, catalog: catalog.id }); + return successResponse({ catalog, songs_added: 0 }); + } + + const [snapshot] = await selectPlaycountSnapshots({ id: validated.snapshot }); + if (!snapshot) { + return errorResponse("Snapshot not found", 404); + } + if (snapshot.account !== accountId) { + return errorResponse("Snapshot belongs to a different account", 403); + } + + // Idempotent re-claim: the run already produced a catalog. + if (snapshot.catalog) { + const existing = await selectCatalogById(snapshot.catalog); + if (existing) { + return successResponse({ catalog: existing, songs_added: 0 }); + } + } + + const { catalog, songsAdded } = await createSnapshotCatalog({ + accountId, + snapshot, + name: validated.name, + }); + return successResponse({ catalog, songs_added: songsAdded }); + } catch (error) { + console.error("Error creating catalog:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/catalog/createSnapshotCatalog.ts b/lib/catalog/createSnapshotCatalog.ts new file mode 100644 index 000000000..ab1ba96c2 --- /dev/null +++ b/lib/catalog/createSnapshotCatalog.ts @@ -0,0 +1,45 @@ +import { Tables } from "@/types/database.types"; +import { insertCatalog } from "@/lib/supabase/catalogs/insertCatalog"; +import { insertAccountCatalog } from "@/lib/supabase/account_catalogs/insertAccountCatalog"; +import { insertCatalogSongs } from "@/lib/supabase/catalog_songs/insertCatalogSongs"; +import { updatePlaycountSnapshot } from "@/lib/supabase/playcount_snapshots/updatePlaycountSnapshot"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; + +const DEFAULT_CATALOG_NAME = "Valuation Catalog"; + +/** + * Creates an account-linked catalog from a valuation snapshot: creates the + * `catalogs` row, links it to the account via `account_catalogs`, adds the + * snapshot's **measured** ISRCs (from `song_measurements`, not the snapshot's + * own `isrcs` column — that's null for album-scoped valuation runs) as + * `catalog_songs`, and records the new catalog on the snapshot (the idempotency + * key for re-claims). + * + * Callers must first confirm the snapshot is owned by `accountId` and not yet + * claimed (`snapshot.catalog` is null). + * + * @param params.accountId - Owning account (already authorized) + * @param params.snapshot - The owned, unclaimed snapshot row + * @param params.name - Optional catalog name; falls back to a default + * @returns The created catalog and the number of songs added + */ +export async function createSnapshotCatalog(params: { + accountId: string; + snapshot: Tables<"playcount_snapshots">; + name?: string; +}): Promise<{ catalog: Tables<"catalogs">; songsAdded: number }> { + const { accountId, snapshot, name } = params; + + const catalog = await insertCatalog(name ?? DEFAULT_CATALOG_NAME); + await insertAccountCatalog({ account: accountId, catalog: catalog.id }); + + const measurements = await selectSongMeasurements({ snapshot: snapshot.id }); + const isrcs = [...new Set(measurements.map(m => m.song))]; + if (isrcs.length > 0) { + await insertCatalogSongs(isrcs.map(isrc => ({ catalog: catalog.id, song: isrc }))); + } + + await updatePlaycountSnapshot(snapshot.id, { catalog: catalog.id }); + + return { catalog, songsAdded: isrcs.length }; +} diff --git a/lib/catalog/validateCreateCatalogBody.ts b/lib/catalog/validateCreateCatalogBody.ts new file mode 100644 index 000000000..b41c058c1 --- /dev/null +++ b/lib/catalog/validateCreateCatalogBody.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const createCatalogBodySchema = z + .object({ + name: z.string().min(1, "name must not be empty").optional(), + snapshot: z.string().uuid("snapshot must be a valid UUID").optional(), + }) + .refine(data => data.name !== undefined || data.snapshot !== undefined, { + message: "Provide at least one of name or snapshot", + }); + +export type CreateCatalogBody = z.infer; + +/** + * Validates a create-catalog request body. + * + * Accepts `{ name?, snapshot? }`; at least one is required. `snapshot` is a + * completed playcount snapshot id (valuation run) to materialize from. The + * owning account is never taken from the body - it is resolved from the + * request credentials by the handler. + * + * @param body - The parsed request body to validate. + * @returns A NextResponse with a 400 error if validation fails, or the + * validated body if it passes. + */ +export function validateCreateCatalogBody(body: unknown): NextResponse | CreateCatalogBody { + const result = createCatalogBodySchema.safeParse(body); + + 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/supabase/account_catalogs/insertAccountCatalog.ts b/lib/supabase/account_catalogs/insertAccountCatalog.ts new file mode 100644 index 000000000..1facd5b63 --- /dev/null +++ b/lib/supabase/account_catalogs/insertAccountCatalog.ts @@ -0,0 +1,21 @@ +import supabase from "../serverClient"; + +/** + * Links a catalog to an account by inserting an `account_catalogs` row. + * + * @param params.account - Account id (owner) + * @param params.catalog - Catalog id to link + * @throws Error if the insert fails + */ +export async function insertAccountCatalog(params: { + account: string; + catalog: string; +}): Promise { + const { error } = await supabase + .from("account_catalogs") + .insert({ account: params.account, catalog: params.catalog }); + + if (error) { + throw new Error(`Failed to link account_catalogs: ${error.message}`); + } +} diff --git a/lib/supabase/catalogs/insertCatalog.ts b/lib/supabase/catalogs/insertCatalog.ts new file mode 100644 index 000000000..ecd0c667b --- /dev/null +++ b/lib/supabase/catalogs/insertCatalog.ts @@ -0,0 +1,19 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +/** + * Inserts a new `catalogs` row and returns it. + * + * @param name - Display name for the catalog + * @returns The inserted catalog row + * @throws Error if the insert fails + */ +export async function insertCatalog(name: string): Promise> { + const { data, error } = await supabase.from("catalogs").insert({ name }).select().single(); + + if (error || !data) { + throw new Error(`Failed to insert catalog: ${error?.message ?? "no row returned"}`); + } + + return data; +} diff --git a/lib/supabase/catalogs/selectCatalogById.ts b/lib/supabase/catalogs/selectCatalogById.ts new file mode 100644 index 000000000..39f961e3d --- /dev/null +++ b/lib/supabase/catalogs/selectCatalogById.ts @@ -0,0 +1,19 @@ +import supabase from "../serverClient"; +import { Tables } from "@/types/database.types"; + +/** + * Selects a single `catalogs` row by id, or null if none exists. + * + * @param id - Catalog id + * @returns The catalog row, or null if not found + * @throws Error if the query fails + */ +export async function selectCatalogById(id: string): Promise | null> { + const { data, error } = await supabase.from("catalogs").select("*").eq("id", id).maybeSingle(); + + if (error) { + throw new Error(`Failed to fetch catalog: ${error.message}`); + } + + return data; +} diff --git a/lib/supabase/song_measurements/selectSongMeasurements.ts b/lib/supabase/song_measurements/selectSongMeasurements.ts index 24d50bd44..1e118d14b 100644 --- a/lib/supabase/song_measurements/selectSongMeasurements.ts +++ b/lib/supabase/song_measurements/selectSongMeasurements.ts @@ -2,13 +2,14 @@ import supabase from "../serverClient"; import { Tables } from "@/types/database.types"; /** - * Select measurements newest-first. Filter by one song or a batch of songs - * (one is required), and optionally by platform/metric. Uses the - * (song, platform, metric, captured_at DESC) series index; pass `limit: 1` - * for the latest capture, omit for the full series. + * Select measurements newest-first. Filter by one song, a batch of songs, or a + * snapshot (one of the three is required), and optionally by platform/metric. + * Uses the (song, platform, metric, captured_at DESC) series index; pass + * `limit: 1` for the latest capture, omit for the full series. * * @param params.song - Single song ISRC filter * @param params.songs - Batch of song ISRCs (e.g. all tracks on an album) + * @param params.snapshot - Snapshot id filter (all measurements captured for a run) * @param params.platform - Optional platform filter (e.g. "spotify") * @param params.metric - Optional metric filter (e.g. "platform_displayed_play_count") * @param params.limit - Optional cap on returned rows @@ -17,17 +18,19 @@ import { Tables } from "@/types/database.types"; export async function selectSongMeasurements({ song, songs, + snapshot, platform, metric, limit, }: { song?: string; songs?: string[]; + snapshot?: string; platform?: string; metric?: string; limit?: number; }): Promise[]> { - if (!song && (!songs || songs.length === 0)) return []; + if (!song && (!songs || songs.length === 0) && !snapshot) return []; let query = supabase .from("song_measurements") @@ -36,6 +39,7 @@ export async function selectSongMeasurements({ if (song) query = query.eq("song", song); if (songs && songs.length > 0) query = query.in("song", songs); + if (snapshot) query = query.eq("snapshot", snapshot); if (platform) query = query.eq("platform", platform); if (metric) query = query.eq("metric", metric); if (limit) query = query.limit(limit);