From cc0511d0714dec01b2d369ee0fbe819a00881123 Mon Sep 17 00:00:00 2001 From: Sweets Sweetman Date: Wed, 29 Jul 2026 14:41:56 -0500 Subject: [PATCH] feat: persist catalog valuations + GET /api/catalogs/{catalogId}/valuations Every valuation run and each first whole-catalog measurements read of the day now writes a row to catalog_valuations, and the new GET returns the series latest-first (limit=1 = current value). chat#1889 row 15. Co-Authored-By: Claude Fable 5 --- .../catalogs/[catalogId]/valuations/route.ts | 32 +++++ .../getCatalogMeasurementsHandler.test.ts | 40 ++++++ .../getCatalogValuationsHandler.test.ts | 115 ++++++++++++++++++ .../__tests__/persistCatalogValuation.test.ts | 94 ++++++++++++++ .../validateGetCatalogValuationsQuery.test.ts | 74 +++++++++++ lib/catalog/getCatalogMeasurementsHandler.ts | 14 +++ lib/catalog/getCatalogValuationsHandler.ts | 58 +++++++++ lib/catalog/persistCatalogValuation.ts | 52 ++++++++ .../validateGetCatalogValuationsQuery.ts | 65 ++++++++++ .../__tests__/insertCatalogValuation.test.ts | 47 +++++++ .../__tests__/selectCatalogValuations.test.ts | 55 +++++++++ .../insertCatalogValuation.ts | 22 ++++ .../selectCatalogValuations.ts | 32 +++++ lib/valuation/runValuationHandler.ts | 11 ++ types/database.types.ts | 44 +++++++ 15 files changed, 755 insertions(+) create mode 100644 app/api/catalogs/[catalogId]/valuations/route.ts create mode 100644 lib/catalog/__tests__/getCatalogValuationsHandler.test.ts create mode 100644 lib/catalog/__tests__/persistCatalogValuation.test.ts create mode 100644 lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts create mode 100644 lib/catalog/getCatalogValuationsHandler.ts create mode 100644 lib/catalog/persistCatalogValuation.ts create mode 100644 lib/catalog/validateGetCatalogValuationsQuery.ts create mode 100644 lib/supabase/catalog_valuations/__tests__/insertCatalogValuation.test.ts create mode 100644 lib/supabase/catalog_valuations/__tests__/selectCatalogValuations.test.ts create mode 100644 lib/supabase/catalog_valuations/insertCatalogValuation.ts create mode 100644 lib/supabase/catalog_valuations/selectCatalogValuations.ts diff --git a/app/api/catalogs/[catalogId]/valuations/route.ts b/app/api/catalogs/[catalogId]/valuations/route.ts new file mode 100644 index 00000000..bcd322c0 --- /dev/null +++ b/app/api/catalogs/[catalogId]/valuations/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getCatalogValuationsHandler } from "@/lib/catalog/getCatalogValuationsHandler"; + +/** + * OPTIONS /api/catalogs/{catalogId}/valuations — CORS preflight. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/catalogs/{catalogId}/valuations — the catalog's persisted + * valuation history, latest-first (limit=1 is the current value). + * + * @param request - The request object. + * @param options - Route options containing params. + * @param options.params - Route params containing the catalogId. + * @returns A NextResponse with the valuation series. + */ +export async function GET( + request: NextRequest, + options: { params: Promise<{ catalogId: string }> }, +) { + const { catalogId } = await options.params; + return getCatalogValuationsHandler(request, catalogId); +} diff --git a/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts index 369f1de6..833963ab 100644 --- a/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts +++ b/lib/catalog/__tests__/getCatalogMeasurementsHandler.test.ts @@ -7,6 +7,7 @@ import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate" import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; import { selectCatalogMeasurementsPage } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsPage"; +import { persistCatalogValuation } from "../persistCatalogValuation"; vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), @@ -24,6 +25,7 @@ vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate", ( vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsPage", () => ({ selectCatalogMeasurementsPage: vi.fn(), })); +vi.mock("../persistCatalogValuation", () => ({ persistCatalogValuation: vi.fn() })); const accountId = "550e8400-e29b-41d4-a716-446655440000"; const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; @@ -122,6 +124,44 @@ describe("getCatalogMeasurementsHandler", () => { expect(body.valuation.high).toBeCloseTo(25 * 0.0035 * 1.6 * 0.6375 * 16, 5); }); + it("persists the whole-catalog band as a daily-deduped history row (chat#1889 row 15)", async () => { + okQuery(); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 120, + totalStreams: 250, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue(pageRows); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); + + const res = await getCatalogMeasurementsHandler(makeRequest(), catalogId); + const body = await res.json(); + + expect(res.status).toBe(200); + expect(persistCatalogValuation).toHaveBeenCalledWith({ + catalogId, + valuation: body.valuation, + measuredSongCount: 120, + totalStreams: 250, + dedupeDaily: true, + }); + }); + + it("does not persist a history row for an artist-scoped read (partial-catalog band)", async () => { + okQuery({ catalogId, artist_account_id: artistAccountId, page: 1, limit: 50 }); + okCatalog(); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 11, + totalStreams: 200, + }); + vi.mocked(selectCatalogMeasurementsPage).mockResolvedValue([pageRows[0]]); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); + + await getCatalogMeasurementsHandler(makeRequest(), catalogId); + + expect(persistCatalogValuation).not.toHaveBeenCalled(); + }); + it("scopes the read to the artist and echoes the applied filter", async () => { okQuery({ catalogId, artist_account_id: artistAccountId, page: 2, limit: 10 }); okCatalog(); diff --git a/lib/catalog/__tests__/getCatalogValuationsHandler.test.ts b/lib/catalog/__tests__/getCatalogValuationsHandler.test.ts new file mode 100644 index 00000000..5ed52b26 --- /dev/null +++ b/lib/catalog/__tests__/getCatalogValuationsHandler.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getCatalogValuationsHandler } from "../getCatalogValuationsHandler"; +import { validateGetCatalogValuationsQuery } from "../validateGetCatalogValuationsQuery"; +import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("../validateGetCatalogValuationsQuery", () => ({ + validateGetCatalogValuationsQuery: vi.fn(), +})); +vi.mock("@/lib/supabase/account_catalogs/selectAccountCatalog", () => ({ + selectAccountCatalog: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_valuations/selectCatalogValuations", () => ({ + selectCatalogValuations: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + +const makeRequest = () => new NextRequest(`http://localhost/api/catalogs/${catalogId}/valuations`); + +const okQuery = () => + vi + .mocked(validateGetCatalogValuationsQuery) + .mockResolvedValue({ accountId, catalogId, limit: 30 }); + +const okCatalog = () => + vi + .mocked(selectAccountCatalog) + .mockResolvedValue({ account: accountId, catalog: catalogId } as never); + +describe("getCatalogValuationsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits with the validator error (auth + params live in the validator)", async () => { + const denied = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateGetCatalogValuationsQuery).mockResolvedValue(denied); + + const response = await getCatalogValuationsHandler(makeRequest(), catalogId); + + expect(response).toBe(denied); + expect(selectAccountCatalog).not.toHaveBeenCalled(); + }); + + it("returns 404 when the catalog is not owned by the caller (or missing)", async () => { + okQuery(); + vi.mocked(selectAccountCatalog).mockResolvedValue(null); + + const response = await getCatalogValuationsHandler(makeRequest(), catalogId); + + expect(response.status).toBe(404); + expect(selectCatalogValuations).not.toHaveBeenCalled(); + }); + + it("returns the valuation series shaped to the documented contract", async () => { + okQuery(); + okCatalog(); + const rows = [ + { + id: "v2", + catalog_id: catalogId, + low: "1000", + mid: "2000", + high: "3000", + measured_song_count: 12, + total_streams: 456789, + measured_at: "2026-07-29T00:00:00Z", + created_at: "2026-07-29T00:00:00Z", + }, + ]; + vi.mocked(selectCatalogValuations).mockResolvedValue(rows as never); + + const response = await getCatalogValuationsHandler(makeRequest(), catalogId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(selectCatalogValuations).toHaveBeenCalledWith({ catalogId, limit: 30 }); + expect(body.valuations).toEqual([ + { + low: 1000, + mid: 2000, + high: 3000, + measured_song_count: 12, + total_streams: 456789, + measured_at: "2026-07-29T00:00:00Z", + }, + ]); + }); + + it("returns an empty series when the catalog has no valuations yet", async () => { + okQuery(); + okCatalog(); + vi.mocked(selectCatalogValuations).mockResolvedValue([]); + + const response = await getCatalogValuationsHandler(makeRequest(), catalogId); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.valuations).toEqual([]); + }); + + it("returns 500 when the select fails", async () => { + okQuery(); + okCatalog(); + vi.mocked(selectCatalogValuations).mockResolvedValue(null); + + const response = await getCatalogValuationsHandler(makeRequest(), catalogId); + + expect(response.status).toBe(500); + }); +}); diff --git a/lib/catalog/__tests__/persistCatalogValuation.test.ts b/lib/catalog/__tests__/persistCatalogValuation.test.ts new file mode 100644 index 00000000..4e8799c7 --- /dev/null +++ b/lib/catalog/__tests__/persistCatalogValuation.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { persistCatalogValuation } from "../persistCatalogValuation"; +import { insertCatalogValuation } from "@/lib/supabase/catalog_valuations/insertCatalogValuation"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; + +vi.mock("@/lib/supabase/catalog_valuations/insertCatalogValuation", () => ({ + insertCatalogValuation: vi.fn(), +})); +vi.mock("@/lib/supabase/catalog_valuations/selectCatalogValuations", () => ({ + selectCatalogValuations: vi.fn(), +})); + +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; +const input = { + catalogId, + valuation: { low: 1000, mid: 2000, high: 3000 }, + measuredSongCount: 12, + totalStreams: 456789, +}; + +describe("persistCatalogValuation", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("inserts a snake_case row from the camelCase inputs", async () => { + vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never); + + await persistCatalogValuation(input); + + expect(insertCatalogValuation).toHaveBeenCalledWith({ + catalog_id: catalogId, + low: 1000, + mid: 2000, + high: 3000, + measured_song_count: 12, + total_streams: 456789, + }); + expect(selectCatalogValuations).not.toHaveBeenCalled(); + }); + + it("never throws when the insert fails (best-effort)", async () => { + vi.mocked(insertCatalogValuation).mockRejectedValue(new Error("down")); + + await expect(persistCatalogValuation(input)).resolves.toBeUndefined(); + }); + + describe("dedupeDaily", () => { + it("skips the insert when a row already exists for today (UTC)", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T18:00:00Z")); + vi.mocked(selectCatalogValuations).mockResolvedValue([ + { measured_at: "2026-07-29T02:00:00Z" } as never, + ]); + + await persistCatalogValuation({ ...input, dedupeDaily: true }); + + expect(selectCatalogValuations).toHaveBeenCalledWith({ catalogId, limit: 1 }); + expect(insertCatalogValuation).not.toHaveBeenCalled(); + }); + + it("inserts when the newest row is from an earlier day", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-29T18:00:00Z")); + vi.mocked(selectCatalogValuations).mockResolvedValue([ + { measured_at: "2026-07-28T23:59:00Z" } as never, + ]); + vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v2" } as never); + + await persistCatalogValuation({ ...input, dedupeDaily: true }); + + expect(insertCatalogValuation).toHaveBeenCalledTimes(1); + }); + + it("inserts when the catalog has no valuations yet", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue([]); + vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never); + + await persistCatalogValuation({ ...input, dedupeDaily: true }); + + expect(insertCatalogValuation).toHaveBeenCalledTimes(1); + }); + + it("still inserts when the dedupe lookup errors (null)", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue(null); + vi.mocked(insertCatalogValuation).mockResolvedValue({ id: "v1" } as never); + + await persistCatalogValuation({ ...input, dedupeDaily: true }); + + expect(insertCatalogValuation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts b/lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts new file mode 100644 index 00000000..53722dcf --- /dev/null +++ b/lib/catalog/__tests__/validateGetCatalogValuationsQuery.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetCatalogValuationsQuery } from "../validateGetCatalogValuationsQuery"; +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 accountId = "550e8400-e29b-41d4-a716-446655440000"; +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + +const makeRequest = (query = "") => + new NextRequest(`http://localhost/api/catalogs/${catalogId}/valuations${query}`); + +const okAuth = () => + vi.mocked(validateAuthContext).mockResolvedValue({ accountId, orgId: null } as never); + +describe("validateGetCatalogValuationsQuery", () => { + beforeEach(() => vi.clearAllMocks()); + + it("short-circuits with the auth error before touching params", async () => { + const denied = NextResponse.json({ status: "error" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(denied as never); + + const result = await validateGetCatalogValuationsQuery(makeRequest(), catalogId); + + expect(result).toBe(denied); + }); + + it("returns the accountId, catalogId and default limit of 30", async () => { + okAuth(); + + const result = await validateGetCatalogValuationsQuery(makeRequest(), catalogId); + + expect(result).toEqual({ accountId, catalogId, limit: 30 }); + }); + + it("accepts an explicit limit", async () => { + okAuth(); + + const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=1"), catalogId); + + expect(result).toEqual({ accountId, catalogId, limit: 1 }); + }); + + it("rejects a non-UUID catalogId with 400", async () => { + okAuth(); + + const result = await validateGetCatalogValuationsQuery(makeRequest(), "not-a-uuid"); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("rejects limit above 100 with 400", async () => { + okAuth(); + + const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=101"), catalogId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("rejects a non-numeric limit with 400", async () => { + okAuth(); + + const result = await validateGetCatalogValuationsQuery(makeRequest("?limit=abc"), catalogId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/catalog/getCatalogMeasurementsHandler.ts b/lib/catalog/getCatalogMeasurementsHandler.ts index ab1b1498..d7da24cd 100644 --- a/lib/catalog/getCatalogMeasurementsHandler.ts +++ b/lib/catalog/getCatalogMeasurementsHandler.ts @@ -3,6 +3,7 @@ import { errorResponse } from "@/lib/networking/errorResponse"; import { successResponse } from "@/lib/networking/successResponse"; import { validateGetCatalogMeasurementsQuery } from "./validateGetCatalogMeasurementsQuery"; import { computeValuationBand } from "./computeValuationBand"; +import { persistCatalogValuation } from "./persistCatalogValuation"; import { getCatalogEarliestReleaseDate } from "./getCatalogEarliestReleaseDate"; import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; @@ -57,6 +58,19 @@ export async function getCatalogMeasurementsHandler( earliestReleaseDate, }); + // Persist the whole-catalog band into the valuation history (chat#1889 + // row 15) — daily-deduped so page refreshes don't mint rows. Artist-scoped + // reads value a slice of the catalog, not the catalog, so they never write. + if (!artistAccountId) { + await persistCatalogValuation({ + catalogId, + valuation, + measuredSongCount: aggregate.measuredSongCount, + totalStreams: aggregate.totalStreams, + dedupeDaily: true, + }); + } + return successResponse({ measurements, pagination: { diff --git a/lib/catalog/getCatalogValuationsHandler.ts b/lib/catalog/getCatalogValuationsHandler.ts new file mode 100644 index 00000000..f917f282 --- /dev/null +++ b/lib/catalog/getCatalogValuationsHandler.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { validateGetCatalogValuationsQuery } from "./validateGetCatalogValuationsQuery"; +import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; + +/** + * GET /api/catalogs/{catalogId}/valuations?limit= + * + * The catalog's persisted valuation history, latest-first — the rows written + * by valuation runs and daily read-time snapshots (chat#1889 row 15). + * limit=1 returns just the current value; the default 30 gives a series for + * trend rendering. Auth and input validation live in + * validateGetCatalogValuationsQuery (SRP). 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 + * @param catalogIdParam - The catalogId path segment + * @returns `{ status, valuations }` with rows `{ low, mid, high, measured_song_count, total_streams, measured_at }` + */ +export async function getCatalogValuationsHandler( + request: NextRequest, + catalogIdParam: string, +): Promise { + try { + const validated = await validateGetCatalogValuationsQuery(request, catalogIdParam); + if (validated instanceof NextResponse) { + return validated; + } + const { accountId, catalogId, limit } = validated; + + const link = await selectAccountCatalog({ accountId, catalogId }); + if (!link) { + return errorResponse("Catalog not found", 404); + } + + const rows = await selectCatalogValuations({ catalogId, limit }); + if (!rows) { + return errorResponse("Internal server error", 500); + } + + return successResponse({ + valuations: rows.map(row => ({ + low: Number(row.low), + mid: Number(row.mid), + high: Number(row.high), + measured_song_count: row.measured_song_count, + total_streams: row.total_streams, + measured_at: row.measured_at, + })), + }); + } catch (error) { + console.error("Error fetching catalog valuations:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/catalog/persistCatalogValuation.ts b/lib/catalog/persistCatalogValuation.ts new file mode 100644 index 00000000..d0640183 --- /dev/null +++ b/lib/catalog/persistCatalogValuation.ts @@ -0,0 +1,52 @@ +import { insertCatalogValuation } from "@/lib/supabase/catalog_valuations/insertCatalogValuation"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; + +/** + * Persist a computed valuation band into the catalog's history + * (catalog_valuations, chat#1889 row 15). Best-effort: valuation reads and + * runs must never fail because history could not be written. + * + * With `dedupeDaily`, at most one row lands per catalog per UTC day — the + * mode for read-time persistence (GET /catalogs/{id}/measurements), where + * every page view would otherwise mint a row. Valuation runs persist + * unconditionally: each run is a fresh measurement worth keeping. + * + * @param params.catalogId - The catalog the band was computed for + * @param params.valuation - The computed band (low/mid/high, whole-catalog scope) + * @param params.measuredSongCount - Songs measured in the aggregate + * @param params.totalStreams - Total streams in the aggregate + * @param params.dedupeDaily - Skip when a row already exists for today (UTC) + */ +export async function persistCatalogValuation({ + catalogId, + valuation, + measuredSongCount, + totalStreams, + dedupeDaily = false, +}: { + catalogId: string; + valuation: { low: number; mid: number; high: number }; + measuredSongCount: number; + totalStreams: number; + dedupeDaily?: boolean; +}): Promise { + try { + if (dedupeDaily) { + const latest = await selectCatalogValuations({ catalogId, limit: 1 }); + const newestDay = latest?.[0]?.measured_at?.slice(0, 10); + const today = new Date().toISOString().slice(0, 10); + if (newestDay === today) return; + } + + await insertCatalogValuation({ + catalog_id: catalogId, + low: valuation.low, + mid: valuation.mid, + high: valuation.high, + measured_song_count: measuredSongCount, + total_streams: totalStreams, + }); + } catch (error) { + console.error("Error persisting catalog valuation:", error); + } +} diff --git a/lib/catalog/validateGetCatalogValuationsQuery.ts b/lib/catalog/validateGetCatalogValuationsQuery.ts new file mode 100644 index 00000000..80a627a4 --- /dev/null +++ b/lib/catalog/validateGetCatalogValuationsQuery.ts @@ -0,0 +1,65 @@ +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 getCatalogValuationsQuerySchema = z.object({ + catalogId: z + .string({ message: "catalogId parameter is required" }) + .uuid("catalogId must be a valid UUID"), + limit: z + .string() + .optional() + .default("30") + .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 GetCatalogValuationsQuery = z.infer & { + accountId: string; +}; + +/** + * Validates GET /api/catalogs/{catalogId}/valuations — auth (Privy bearer or + * x-api-key, resolved to the caller's accountId), the catalogId path segment + * (uuid), and the optional limit (1–100, default 30; limit=1 is the current + * value). Auth runs first, per the validator convention of the catalogs + * family. The path id always wins over anything in the query string. + * + * @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 async function validateGetCatalogValuationsQuery( + request: NextRequest, + catalogId: string, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + const result = getCatalogValuationsQuerySchema.safeParse({ + ...Object.fromEntries(searchParams.entries()), + catalogId, + }); + + 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, accountId: authResult.accountId }; +} diff --git a/lib/supabase/catalog_valuations/__tests__/insertCatalogValuation.test.ts b/lib/supabase/catalog_valuations/__tests__/insertCatalogValuation.test.ts new file mode 100644 index 00000000..bf37c70d --- /dev/null +++ b/lib/supabase/catalog_valuations/__tests__/insertCatalogValuation.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { insertCatalogValuation } from "@/lib/supabase/catalog_valuations/insertCatalogValuation"; + +const insertChain = vi.fn(); +const selectChain = vi.fn(); +const singleChain = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ insert: insertChain })), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + insertChain.mockReturnValue({ select: selectChain }); + selectChain.mockReturnValue({ single: singleChain }); +}); + +const row = { + catalog_id: "740d5050-40ec-4892-a040-b78bb50fef2f", + low: 1000, + mid: 2000, + high: 3000, + measured_song_count: 12, + total_streams: 456789, +}; + +describe("insertCatalogValuation", () => { + it("inserts the valuation row and returns it", async () => { + const inserted = { id: "val-1", ...row, measured_at: "2026-07-29T00:00:00Z" }; + singleChain.mockResolvedValue({ data: inserted, error: null }); + + const result = await insertCatalogValuation(row); + + expect(result).toEqual(inserted); + expect(insertChain).toHaveBeenCalledWith(row); + }); + + it("returns null when supabase reports an error", async () => { + singleChain.mockResolvedValue({ data: null, error: { message: "down" } }); + + const result = await insertCatalogValuation(row); + + expect(result).toBeNull(); + }); +}); diff --git a/lib/supabase/catalog_valuations/__tests__/selectCatalogValuations.test.ts b/lib/supabase/catalog_valuations/__tests__/selectCatalogValuations.test.ts new file mode 100644 index 00000000..efb6024c --- /dev/null +++ b/lib/supabase/catalog_valuations/__tests__/selectCatalogValuations.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; + +const selectChain = vi.fn(); +const eqChain = vi.fn(); +const orderChain = vi.fn(); +const limitChain = vi.fn(); + +vi.mock("@/lib/supabase/serverClient", () => ({ + default: { + from: vi.fn(() => ({ select: selectChain })), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + selectChain.mockReturnValue({ eq: eqChain }); + eqChain.mockReturnValue({ order: orderChain }); + orderChain.mockReturnValue({ limit: limitChain }); +}); + +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + +describe("selectCatalogValuations", () => { + it("returns the catalog's valuations latest-first with the requested limit", async () => { + const rows = [ + { id: "v2", catalog_id: catalogId, measured_at: "2026-07-29T00:00:00Z" }, + { id: "v1", catalog_id: catalogId, measured_at: "2026-07-28T00:00:00Z" }, + ]; + limitChain.mockResolvedValue({ data: rows, error: null }); + + const result = await selectCatalogValuations({ catalogId, limit: 30 }); + + expect(result).toEqual(rows); + expect(eqChain).toHaveBeenCalledWith("catalog_id", catalogId); + expect(orderChain).toHaveBeenCalledWith("measured_at", { ascending: false }); + expect(limitChain).toHaveBeenCalledWith(30); + }); + + it("returns null when supabase reports an error", async () => { + limitChain.mockResolvedValue({ data: null, error: { message: "down" } }); + + const result = await selectCatalogValuations({ catalogId, limit: 1 }); + + expect(result).toBeNull(); + }); + + it("returns an empty array when the catalog has no valuations yet", async () => { + limitChain.mockResolvedValue({ data: [], error: null }); + + const result = await selectCatalogValuations({ catalogId, limit: 30 }); + + expect(result).toEqual([]); + }); +}); diff --git a/lib/supabase/catalog_valuations/insertCatalogValuation.ts b/lib/supabase/catalog_valuations/insertCatalogValuation.ts new file mode 100644 index 00000000..181d00ac --- /dev/null +++ b/lib/supabase/catalog_valuations/insertCatalogValuation.ts @@ -0,0 +1,22 @@ +import supabase from "../serverClient"; +import type { Tables, TablesInsert } from "@/types/database.types"; + +/** + * Insert one valuation history row for a catalog — the persisted output of + * computeValuationBand at the moment it was computed (chat#1889 row 15). + * + * @param row - The valuation row (catalog_id, band, aggregates) + * @returns The inserted row, or null on error + */ +export async function insertCatalogValuation( + row: TablesInsert<"catalog_valuations">, +): Promise | null> { + const { data, error } = await supabase.from("catalog_valuations").insert(row).select().single(); + + if (error) { + console.error("Error inserting catalog_valuations:", error); + return null; + } + + return data; +} diff --git a/lib/supabase/catalog_valuations/selectCatalogValuations.ts b/lib/supabase/catalog_valuations/selectCatalogValuations.ts new file mode 100644 index 00000000..af1779c0 --- /dev/null +++ b/lib/supabase/catalog_valuations/selectCatalogValuations.ts @@ -0,0 +1,32 @@ +import supabase from "../serverClient"; +import type { Tables } from "@/types/database.types"; + +/** + * Select one catalog's valuation history, latest-first. limit=1 is the + * current value; larger limits return the series for trend rendering. + * + * @param params.catalogId - The catalog whose history to read + * @param params.limit - Max rows to return (route default 30, max 100) + * @returns The rows latest-first (possibly empty), or null on error + */ +export async function selectCatalogValuations({ + catalogId, + limit, +}: { + catalogId: string; + limit: number; +}): Promise[] | null> { + const { data, error } = await supabase + .from("catalog_valuations") + .select("*") + .eq("catalog_id", catalogId) + .order("measured_at", { ascending: false }) + .limit(limit); + + if (error) { + console.error("Error fetching catalog_valuations:", error); + return null; + } + + return data || []; +} diff --git a/lib/valuation/runValuationHandler.ts b/lib/valuation/runValuationHandler.ts index fac1aa78..9e9e703a 100644 --- a/lib/valuation/runValuationHandler.ts +++ b/lib/valuation/runValuationHandler.ts @@ -10,6 +10,7 @@ import { createSnapshotCatalog } from "@/lib/catalog/createSnapshotCatalog"; import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; import { getCatalogEarliestReleaseDate } from "@/lib/catalog/getCatalogEarliestReleaseDate"; import { computeValuationBand } from "@/lib/catalog/computeValuationBand"; +import { persistCatalogValuation } from "@/lib/catalog/persistCatalogValuation"; import { sendValuationReportEmail } from "@/lib/emails/valuationReport/sendValuationReportEmail"; import { captureValuationLead } from "@/lib/valuation/captureValuationLead"; import { validateRunValuationRequest } from "./validateRunValuationRequest"; @@ -142,6 +143,16 @@ export async function runValuationHandler(request: NextRequest): Promise