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__/getCatalogValuationDelta.test.ts b/lib/catalog/__tests__/getCatalogValuationDelta.test.ts new file mode 100644 index 00000000..60744d15 --- /dev/null +++ b/lib/catalog/__tests__/getCatalogValuationDelta.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getCatalogValuationDelta } from "../getCatalogValuationDelta"; +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { getCatalogEarliestReleaseDate } from "../getCatalogEarliestReleaseDate"; + +vi.mock("@/lib/supabase/catalog_valuations/selectCatalogValuations", () => ({ + selectCatalogValuations: vi.fn(), +})); +vi.mock("@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate", () => ({ + selectCatalogMeasurementsAggregate: vi.fn(), +})); +vi.mock("../getCatalogEarliestReleaseDate", () => ({ getCatalogEarliestReleaseDate: vi.fn() })); + +const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; +const row = (mid: number, measured_at: string) => ({ + low: mid * 0.8, + mid, + high: mid * 1.2, + measured_at, +}); + +describe("getCatalogValuationDelta", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns current + previous from the two most recent history rows", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue([ + row(1_100_000, "2026-07-29T00:00:00Z"), + row(1_000_000, "2026-07-22T00:00:00Z"), + ] as never); + + const delta = await getCatalogValuationDelta({ catalogId }); + + expect(selectCatalogValuations).toHaveBeenCalledWith({ catalogId, limit: 2 }); + expect(delta).toMatchObject({ + current: { mid: 1_100_000, measured_at: "2026-07-29T00:00:00Z" }, + previous: { mid: 1_000_000 }, + }); + expect(selectCatalogMeasurementsAggregate).not.toHaveBeenCalled(); + }); + + it("returns a baseline (previous null) when only one history row exists", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue([ + row(1_100_000, "2026-07-29T00:00:00Z"), + ] as never); + + const delta = await getCatalogValuationDelta({ catalogId }); + + expect(delta?.previous).toBeNull(); + expect(delta?.current.mid).toBe(1_100_000); + }); + + it("falls back to the read-time band when the history is empty but songs are measured", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue([]); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 10, + totalStreams: 1_000_000, + }); + vi.mocked(getCatalogEarliestReleaseDate).mockResolvedValue("2016-07-01"); + + const delta = await getCatalogValuationDelta({ catalogId }); + + expect(delta).not.toBeNull(); + expect(delta?.previous).toBeNull(); + expect(delta?.current.mid).toBeGreaterThan(0); + }); + + it("returns null when the catalog has no measured songs at all", async () => { + vi.mocked(selectCatalogValuations).mockResolvedValue([]); + vi.mocked(selectCatalogMeasurementsAggregate).mockResolvedValue({ + measuredSongCount: 0, + totalStreams: 0, + }); + + const delta = await getCatalogValuationDelta({ catalogId }); + + expect(delta).toBeNull(); + }); + + it("returns null and never throws when the history read errors", async () => { + vi.mocked(selectCatalogValuations).mockRejectedValue(new Error("down")); + + await expect(getCatalogValuationDelta({ catalogId })).resolves.toBeNull(); + }); +}); 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/getCatalogValuationDelta.ts b/lib/catalog/getCatalogValuationDelta.ts new file mode 100644 index 00000000..221c98d3 --- /dev/null +++ b/lib/catalog/getCatalogValuationDelta.ts @@ -0,0 +1,79 @@ +import { selectCatalogValuations } from "@/lib/supabase/catalog_valuations/selectCatalogValuations"; +import { selectCatalogMeasurementsAggregate } from "@/lib/supabase/song_measurements/selectCatalogMeasurementsAggregate"; +import { getCatalogEarliestReleaseDate } from "./getCatalogEarliestReleaseDate"; +import { computeValuationBand } from "./computeValuationBand"; + +export interface CatalogValuationPoint { + low: number; + mid: number; + high: number; + measured_at: string; +} + +export interface CatalogValuationDelta { + current: CatalogValuationPoint; + previous: CatalogValuationPoint | null; +} + +/** + * The catalog's current value and the previous measurement to diff against — + * the pair the delta-led report email renders (chat#1911 row 5). Reads the + * two most recent history rows; a catalog with history but no second row is + * a baseline (previous null). A catalog with no history yet falls back to + * the read-time band when songs are measured, so the first report after the + * history table ships still leads with a number. + * + * Best-effort: returns null (no delta, email unchanged) on empty catalogs + * and on any failure — never throws, a send must not die on this. + * + * @param params.catalogId - The catalog to value + * @returns The current/previous pair, or null when there is nothing to say + */ +export async function getCatalogValuationDelta({ + catalogId, +}: { + catalogId: string; +}): Promise { + try { + const rows = await selectCatalogValuations({ catalogId, limit: 2 }); + if (rows && rows.length > 0) { + const [current, previous] = rows; + return { + current: toPoint(current), + previous: previous ? toPoint(previous) : null, + }; + } + + const [aggregate, earliestReleaseDate] = await Promise.all([ + selectCatalogMeasurementsAggregate({ catalogId }), + getCatalogEarliestReleaseDate(catalogId), + ]); + if (!aggregate || aggregate.measuredSongCount === 0) return null; + + const { valuation } = computeValuationBand({ + totalStreams: aggregate.totalStreams, + earliestReleaseDate, + }); + return { + current: { ...valuation, measured_at: new Date().toISOString() }, + previous: null, + }; + } catch (error) { + console.error("Error resolving catalog valuation delta:", error); + return null; + } +} + +function toPoint(row: { + low: number; + mid: number; + high: number; + measured_at: string; +}): CatalogValuationPoint { + return { + low: Number(row.low), + mid: Number(row.mid), + high: Number(row.high), + measured_at: row.measured_at, + }; +} 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/emails/__tests__/processAndSendEmail.test.ts b/lib/emails/__tests__/processAndSendEmail.test.ts index 774998a3..22fb9d48 100644 --- a/lib/emails/__tests__/processAndSendEmail.test.ts +++ b/lib/emails/__tests__/processAndSendEmail.test.ts @@ -13,6 +13,11 @@ vi.mock("@/lib/supabase/rooms/selectRoomWithArtist", () => ({ selectRoomWithArtist: (...args: unknown[]) => mockSelectRoomWithArtist(...args), })); +const mockGetCatalogValuationDelta = vi.fn(); +vi.mock("@/lib/catalog/getCatalogValuationDelta", () => ({ + getCatalogValuationDelta: (...args: unknown[]) => mockGetCatalogValuationDelta(...args), +})); + describe("processAndSendEmail", () => { beforeEach(() => { vi.clearAllMocks(); @@ -112,6 +117,87 @@ describe("processAndSendEmail", () => { expect(sent.html).toContain("Plus Jakarta Sans"); }); + // chat#1911 row 5: a catalog_id leads the email with the value delta. + describe("catalog_id valuation delta", () => { + const delta = { + current: { + low: 880_000, + mid: 1_100_000, + high: 1_320_000, + measured_at: "2026-07-29T00:00:00Z", + }, + previous: { + low: 800_000, + mid: 1_000_000, + high: 1_200_000, + measured_at: "2026-07-22T00:00:00Z", + }, + }; + + it("prefixes the subject and prepends the hero when a delta resolves", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-d1" }); + mockGetCatalogValuationDelta.mockResolvedValue(delta); + + await processAndSendEmail({ + to: ["user@example.com"], + subject: "Weekly report", + text: "Body", + catalog_id: "740d5050-40ec-4892-a040-b78bb50fef2f", + }); + + expect(mockGetCatalogValuationDelta).toHaveBeenCalledWith({ + catalogId: "740d5050-40ec-4892-a040-b78bb50fef2f", + }); + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ + subject: "$1.1M (+10.0%) · Weekly report", + html: expect.stringContaining("since your last measurement"), + }), + ); + }); + + it("sends the email unchanged when no delta resolves (unowned/empty catalog)", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-d2" }); + mockGetCatalogValuationDelta.mockResolvedValue(null); + + await processAndSendEmail({ + to: ["user@example.com"], + subject: "Weekly report", + text: "Body", + catalog_id: "740d5050-40ec-4892-a040-b78bb50fef2f", + }); + + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ subject: "Weekly report" }), + ); + }); + + it("still sends when the delta lookup throws (best-effort)", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-d3" }); + mockGetCatalogValuationDelta.mockRejectedValue(new Error("down")); + + const result = await processAndSendEmail({ + to: ["user@example.com"], + subject: "Weekly report", + text: "Body", + catalog_id: "740d5050-40ec-4892-a040-b78bb50fef2f", + }); + + expect(result.success).toBe(true); + expect(mockSendEmailWithResend).toHaveBeenCalledWith( + expect.objectContaining({ subject: "Weekly report" }), + ); + }); + + it("never touches the delta path without a catalog_id", async () => { + mockSendEmailWithResend.mockResolvedValue({ id: "email-d4" }); + + await processAndSendEmail({ to: ["user@example.com"], subject: "T", text: "B" }); + + expect(mockGetCatalogValuationDelta).not.toHaveBeenCalled(); + }); + }); + it("returns error when Resend fails", async () => { const errorResponse = NextResponse.json( { error: { message: "Rate limited" } }, diff --git a/lib/emails/__tests__/sendEmailHandler.test.ts b/lib/emails/__tests__/sendEmailHandler.test.ts index 8bc132c1..6470bdbc 100644 --- a/lib/emails/__tests__/sendEmailHandler.test.ts +++ b/lib/emails/__tests__/sendEmailHandler.test.ts @@ -5,6 +5,11 @@ import { sendEmailHandler } from "../sendEmailHandler"; const mockValidateSendEmailBody = vi.fn(); const mockProcessAndSendEmail = vi.fn(); const mockLogEmailAttempt = vi.fn(); +const mockSelectAccountCatalog = vi.fn(); + +vi.mock("@/lib/supabase/account_catalogs/selectAccountCatalog", () => ({ + selectAccountCatalog: (...args: unknown[]) => mockSelectAccountCatalog(...args), +})); vi.mock("@/lib/emails/validateSendEmailBody", () => ({ validateSendEmailBody: (...args: unknown[]) => mockValidateSendEmailBody(...args), @@ -51,6 +56,57 @@ describe("sendEmailHandler", () => { }); }); + // chat#1911 row 5: catalog_id passes through ONLY when the caller owns the + // catalog - otherwise it is dropped and the email sends unchanged, so a + // caller can never lead their email with someone else's valuation. + describe("catalog_id ownership gate", () => { + const catalogId = "740d5050-40ec-4892-a040-b78bb50fef2f"; + const withCatalog = () => + mockValidateSendEmailBody.mockResolvedValue({ + rawBody: "{}", + data: { + to: ["dest@example.com"], + subject: "Weekly report", + text: "body", + catalog_id: catalogId, + accountId: "account-123", + }, + }); + + it("passes catalog_id through when the account owns the catalog", async () => { + withCatalog(); + mockSelectAccountCatalog.mockResolvedValue({ account: "account-123", catalog: catalogId }); + + await sendEmailHandler(createRequest()); + + expect(mockSelectAccountCatalog).toHaveBeenCalledWith({ + accountId: "account-123", + catalogId, + }); + expect(mockProcessAndSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ catalog_id: catalogId }), + ); + }); + + it("drops catalog_id when the catalog is not owned by the caller", async () => { + withCatalog(); + mockSelectAccountCatalog.mockResolvedValue(null); + + const response = await sendEmailHandler(createRequest()); + + expect(response.status).toBe(200); + expect(mockProcessAndSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ catalog_id: undefined }), + ); + }); + + it("never checks ownership when no catalog_id is sent", async () => { + await sendEmailHandler(createRequest()); + + expect(mockSelectAccountCatalog).not.toHaveBeenCalled(); + }); + }); + it("sends to the validated recipients and maps chat_id to the footer link", async () => { const response = await sendEmailHandler(createRequest()); diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts index f3e4a445..2e1a6286 100644 --- a/lib/emails/processAndSendEmail.ts +++ b/lib/emails/processAndSendEmail.ts @@ -1,6 +1,9 @@ import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { getEmailFooter } from "@/lib/emails/getEmailFooter"; import { renderEmailLayout } from "@/lib/emails/renderEmailLayout"; +import { getCatalogValuationDelta } from "@/lib/catalog/getCatalogValuationDelta"; +import { buildValuationDeltaSubjectPrefix } from "@/lib/emails/valuationDelta/buildValuationDeltaSubjectPrefix"; +import { renderValuationDeltaHero } from "@/lib/emails/valuationDelta/renderValuationDeltaHero"; import { selectRoomWithArtist } from "@/lib/supabase/rooms/selectRoomWithArtist"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; import { NextResponse } from "next/server"; @@ -14,6 +17,8 @@ export interface ProcessAndSendEmailInput { html?: string; headers?: Record; room_id?: string; + /** Ownership-checked by the caller — leads the email with the catalog's value delta (chat#1911 row 5). */ + catalog_id?: string; } export interface ProcessAndSendEmailSuccess { @@ -39,11 +44,28 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS export async function processAndSendEmail( input: ProcessAndSendEmailInput, ): Promise { - const { to, cc = [], subject, text, html = "", headers = {}, room_id } = input; + const { to, cc = [], subject, text, html = "", headers = {}, room_id, catalog_id } = input; const roomData = room_id ? await selectRoomWithArtist(room_id) : null; const footer = getEmailFooter(room_id, roomData?.artist_name || undefined); - const bodyHtml = html || (text ? await marked(text) : ""); + let bodyHtml = html || (text ? await marked(text) : ""); + let finalSubject = subject; + + // Lead with the catalog's value delta when the caller asked for it + // (chat#1911 row 5): subject prefix + hero block above the body. Best-effort + // by contract — an unowned/empty catalog or a lookup failure sends the email + // unchanged; the delta must never cost a delivery. + if (catalog_id) { + try { + const delta = await getCatalogValuationDelta({ catalogId: catalog_id }); + if (delta) { + finalSubject = buildValuationDeltaSubjectPrefix(delta) + subject; + bodyHtml = renderValuationDeltaHero(delta) + bodyHtml; + } + } catch (error) { + console.error("Valuation delta enrichment failed:", error); + } + } // Wrap in the shared house-style layout so every outbound email — including // the live weekly-report send that flows through here — shares one visual // language with the welcome/valuation emails (recoupable/chat#1885 @@ -55,7 +77,7 @@ export async function processAndSendEmail( from: RECOUP_FROM_EMAIL, to, cc: cc.length > 0 ? cc : undefined, - subject, + subject: finalSubject, html: htmlWithLayout, headers, }); diff --git a/lib/emails/sendEmailHandler.ts b/lib/emails/sendEmailHandler.ts index 6fcc3ab5..96f9df41 100644 --- a/lib/emails/sendEmailHandler.ts +++ b/lib/emails/sendEmailHandler.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateSendEmailBody } from "@/lib/emails/validateSendEmailBody"; import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; +import { selectAccountCatalog } from "@/lib/supabase/account_catalogs/selectAccountCatalog"; import { logEmailAttempt, type EmailAttemptLog } from "@/lib/emails/logEmailAttempt"; /** @@ -29,8 +30,19 @@ export async function sendEmailHandler(request: NextRequest): Promise ({ + low: mid * 0.8, + mid, + high: mid * 1.2, + measured_at: "2026-07-29T00:00:00Z", +}); + +describe("buildValuationDeltaSubjectPrefix", () => { + it("leads with the current mid and the signed percent change", () => { + expect( + buildValuationDeltaSubjectPrefix({ current: cur(1_100_000), previous: cur(1_000_000) }), + ).toBe("$1.1M (+10.0%) · "); + }); + + it("keeps the sign on a decline", () => { + expect( + buildValuationDeltaSubjectPrefix({ current: cur(900_000), previous: cur(1_000_000) }), + ).toBe("$900K (-10.0%) · "); + }); + + it("marks the baseline when there is no previous measurement", () => { + expect(buildValuationDeltaSubjectPrefix({ current: cur(1_100_000), previous: null })).toBe( + "$1.1M baseline · ", + ); + }); + + it("omits the percent when the previous mid is zero", () => { + expect(buildValuationDeltaSubjectPrefix({ current: cur(1_100_000), previous: cur(0) })).toBe( + "$1.1M · ", + ); + }); +}); diff --git a/lib/emails/valuationDelta/__tests__/renderValuationDeltaHero.test.ts b/lib/emails/valuationDelta/__tests__/renderValuationDeltaHero.test.ts new file mode 100644 index 00000000..74f78d42 --- /dev/null +++ b/lib/emails/valuationDelta/__tests__/renderValuationDeltaHero.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { renderValuationDeltaHero } from "../renderValuationDeltaHero"; + +const cur = (mid: number, at = "2026-07-29T00:00:00Z") => ({ + low: mid * 0.8, + mid, + high: mid * 1.2, + measured_at: at, +}); + +describe("renderValuationDeltaHero", () => { + it("renders previous → current with the signed percent change", () => { + const html = renderValuationDeltaHero({ + current: cur(1_100_000), + previous: cur(1_000_000, "2026-07-22T00:00:00Z"), + }); + + expect(html).toContain("$1M"); + expect(html).toContain("$1.1M"); + expect(html).toContain("+10.0%"); + expect(html).toContain("since your last measurement"); + }); + + it("renders the baseline framing when there is no previous measurement", () => { + const html = renderValuationDeltaHero({ current: cur(1_100_000), previous: null }); + + expect(html).toContain("$1.1M"); + expect(html).toContain("baseline is set"); + expect(html).not.toMatch(/[+-]\d+\.\d%/); + }); + + it("contains no em or en dashes (user-facing copy rule)", () => { + for (const previous of [cur(1_000_000), null]) { + expect(renderValuationDeltaHero({ current: cur(1_100_000), previous })).not.toMatch(/[—–]/); + } + }); +}); diff --git a/lib/emails/valuationDelta/buildValuationDeltaSubjectPrefix.ts b/lib/emails/valuationDelta/buildValuationDeltaSubjectPrefix.ts new file mode 100644 index 00000000..668675bb --- /dev/null +++ b/lib/emails/valuationDelta/buildValuationDeltaSubjectPrefix.ts @@ -0,0 +1,19 @@ +import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; +import type { CatalogValuationDelta } from "@/lib/catalog/getCatalogValuationDelta"; + +/** + * The subject-line prefix for a delta-led report email (chat#1911 row 5): + * `$1.1M (+10.0%) · ` when a previous measurement exists, `$1.1M baseline · ` + * for a first measurement, and just the value when the previous mid was zero + * (a percent against zero is noise). The subject leading with the number is + * the whole point — it is the line that gets the report opened. + */ +export function buildValuationDeltaSubjectPrefix(delta: CatalogValuationDelta): string { + const value = formatCompactUsd(delta.current.mid); + if (!delta.previous) return `${value} baseline · `; + if (delta.previous.mid <= 0) return `${value} · `; + + const pct = ((delta.current.mid - delta.previous.mid) / delta.previous.mid) * 100; + const signed = `${pct >= 0 ? "+" : "-"}${Math.abs(pct).toFixed(1)}%`; + return `${value} (${signed}) · `; +} diff --git a/lib/emails/valuationDelta/renderValuationDeltaHero.ts b/lib/emails/valuationDelta/renderValuationDeltaHero.ts new file mode 100644 index 00000000..a81eb3e8 --- /dev/null +++ b/lib/emails/valuationDelta/renderValuationDeltaHero.ts @@ -0,0 +1,31 @@ +import { formatCompactUsd } from "@/lib/emails/valuationReport/formatCompactUsd"; +import type { CatalogValuationDelta } from "@/lib/catalog/getCatalogValuationDelta"; + +/** + * The hero block prepended to a delta-led report email (chat#1911 row 5): + * previous value, arrow, current value, signed percent change - or, for a + * first measurement, the current band with the baseline note. Table-based + * inline-style markup like the valuation report blocks, so it renders in + * every mail client the layout already supports. + */ +export function renderValuationDeltaHero(delta: CatalogValuationDelta): string { + const current = formatCompactUsd(delta.current.mid); + + if (delta.previous && delta.previous.mid > 0) { + const previous = formatCompactUsd(delta.previous.mid); + const pct = ((delta.current.mid - delta.previous.mid) / delta.previous.mid) * 100; + const signed = `${pct >= 0 ? "+" : "-"}${Math.abs(pct).toFixed(1)}%`; + const color = pct >= 0 ? "#0a7d33" : "#b42318"; + return `
+

Your catalog value

+

${previous} ${current} ${signed}

+

since your last measurement

+
`; + } + + return `
+

Your catalog value

+

${current}

+

Your baseline is set. The next report shows the change.

+
`; +} 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