diff --git a/app/api/research/albums/[id]/measurements/route.ts b/app/api/research/albums/[id]/measurements/route.ts new file mode 100644 index 000000000..a9dd39c8c --- /dev/null +++ b/app/api/research/albums/[id]/measurements/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getAlbumMeasurementsHandler } from "@/lib/research/measurements/getAlbumMeasurementsHandler"; + +export const maxDuration = 60; + +/** + * OPTIONS /api/research/albums/{id}/measurements — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/albums/{id}/measurements — latest measured count per track. + * + * @param request - The incoming HTTP request. + * @param options - Route options containing params. + * @param options.params - Route params containing the album id. + * @returns JSON album measurements or error + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + const { id } = await options.params; + return getAlbumMeasurementsHandler(request, id); +} diff --git a/app/api/research/tracks/[id]/measurements/route.ts b/app/api/research/tracks/[id]/measurements/route.ts new file mode 100644 index 000000000..ed3598014 --- /dev/null +++ b/app/api/research/tracks/[id]/measurements/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getTrackMeasurementsHandler } from "@/lib/research/measurements/getTrackMeasurementsHandler"; + +export const maxDuration = 60; + +/** + * OPTIONS /api/research/tracks/{id}/measurements — CORS preflight. + * + * @returns CORS-enabled 200 response + */ +export async function OPTIONS() { + return new NextResponse(null, { status: 200, headers: getCorsHeaders() }); +} + +/** + * GET /api/research/tracks/{id}/measurements — a track's measured series, or a + * derived `aggregate=run_rate` projection. + * + * @param request - The incoming HTTP request. + * @param options - Route options containing params. + * @param options.params - Route params containing the track id. + * @returns JSON measurements or error + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + const { id } = await options.params; + return getTrackMeasurementsHandler(request, id); +} diff --git a/lib/research/measurements/__tests__/getAlbumMeasurements.test.ts b/lib/research/measurements/__tests__/getAlbumMeasurements.test.ts new file mode 100644 index 000000000..0cad8cef8 --- /dev/null +++ b/lib/research/measurements/__tests__/getAlbumMeasurements.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getAlbumMeasurements } from "../getAlbumMeasurements"; +import { getAlbumPlaycounts } from "@/lib/research/playcounts/getAlbumPlaycounts"; + +vi.mock("@/lib/research/playcounts/getAlbumPlaycounts", () => ({ getAlbumPlaycounts: vi.fn() })); + +describe("getAlbumMeasurements", () => { + beforeEach(() => vi.clearAllMocks()); + + it("remaps the album playcounts read into the measurements shape", async () => { + vi.mocked(getAlbumPlaycounts).mockResolvedValue({ + data: { + status: "success", + album: { spotify_album_id: "AL1" }, + playcounts: [ + { + isrc: "I1", + spotify_track_id: "T1", + name: "Song", + platform_displayed_play_count: 500, + captured_at: "2026-06-12T00:00:00+00:00", + data_source: "apify_spotify_playcount", + }, + ], + }, + }); + + const r = await getAlbumMeasurements({ accountId: "acc_1", spotifyAlbumId: "AL1" }); + + expect(getAlbumPlaycounts).toHaveBeenCalledWith({ accountId: "acc_1", spotifyAlbumId: "AL1" }); + expect(r).toEqual({ + data: { + status: "success", + id: "AL1", + platform: "spotify", + metric: "platform_displayed_play_count", + measurements: [ + { + isrc: "I1", + spotify_track_id: "T1", + name: "Song", + value: 500, + captured_at: "2026-06-12T00:00:00+00:00", + data_source: "apify_spotify_playcount", + }, + ], + }, + }); + }); + + it("propagates a 404 from the underlying read", async () => { + vi.mocked(getAlbumPlaycounts).mockResolvedValue({ error: "No snapshot", status: 404 }); + const r = await getAlbumMeasurements({ accountId: "acc_1", spotifyAlbumId: "AL1" }); + expect(r).toEqual({ error: "No snapshot", status: 404 }); + }); +}); diff --git a/lib/research/measurements/__tests__/getAlbumMeasurementsHandler.test.ts b/lib/research/measurements/__tests__/getAlbumMeasurementsHandler.test.ts new file mode 100644 index 000000000..dcf32e684 --- /dev/null +++ b/lib/research/measurements/__tests__/getAlbumMeasurementsHandler.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getAlbumMeasurementsHandler } from "../getAlbumMeasurementsHandler"; +import { validateGetAlbumMeasurementsRequest } from "../validateGetAlbumMeasurementsRequest"; +import { getAlbumMeasurements } from "../getAlbumMeasurements"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({})) })); +vi.mock("../validateGetAlbumMeasurementsRequest", () => ({ + validateGetAlbumMeasurementsRequest: vi.fn(), +})); +vi.mock("../getAlbumMeasurements", () => ({ getAlbumMeasurements: vi.fn() })); + +const req = () => new NextRequest("http://x/api/research/albums/AL1/measurements"); +const validated = { accountId: "acc_1", spotifyAlbumId: "AL1" } as never; + +describe("getAlbumMeasurementsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the album measurements payload on success", async () => { + vi.mocked(validateGetAlbumMeasurementsRequest).mockResolvedValue(validated); + vi.mocked(getAlbumMeasurements).mockResolvedValue({ + data: { status: "success", id: "AL1", measurements: [] }, + }); + const res = await getAlbumMeasurementsHandler(req(), "AL1"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ id: "AL1" }); + }); + + it("short-circuits with the validation response", async () => { + vi.mocked(validateGetAlbumMeasurementsRequest).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }) as never, + ); + const res = await getAlbumMeasurementsHandler(req(), "AL1"); + expect(res.status).toBe(401); + expect(getAlbumMeasurements).not.toHaveBeenCalled(); + }); + + it("maps error results to error responses", async () => { + vi.mocked(validateGetAlbumMeasurementsRequest).mockResolvedValue(validated); + vi.mocked(getAlbumMeasurements).mockResolvedValue({ error: "No snapshot", status: 404 }); + const res = await getAlbumMeasurementsHandler(req(), "AL1"); + expect(res.status).toBe(404); + }); + + it("returns 500 on unexpected errors", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(validateGetAlbumMeasurementsRequest).mockRejectedValue(new Error("boom")); + const res = await getAlbumMeasurementsHandler(req(), "AL1"); + expect(res.status).toBe(500); + consoleError.mockRestore(); + }); +}); diff --git a/lib/research/measurements/__tests__/getTrackMeasurements.test.ts b/lib/research/measurements/__tests__/getTrackMeasurements.test.ts new file mode 100644 index 000000000..fa58a7d8c --- /dev/null +++ b/lib/research/measurements/__tests__/getTrackMeasurements.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { getTrackMeasurements } from "../getTrackMeasurements"; +import { resolveTrackIsrc } from "../resolveTrackIsrc"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; +import { computePlaycountDeltas } from "@/lib/research/playcounts/computePlaycountDeltas"; +import { deductCredits } from "@/lib/research/deductCredits"; + +vi.mock("../resolveTrackIsrc", () => ({ resolveTrackIsrc: vi.fn() })); +vi.mock("@/lib/supabase/song_measurements/selectSongMeasurements", () => ({ + selectSongMeasurements: vi.fn(), +})); +vi.mock("@/lib/research/playcounts/computePlaycountDeltas", () => ({ + computePlaycountDeltas: vi.fn(), +})); +vi.mock("@/lib/research/deductCredits", () => ({ deductCredits: vi.fn() })); + +const base = { + accountId: "acc_1", + id: "USQY51771120", + platform: "spotify", + metric: "platform_displayed_play_count", + windowDays: 365, +}; + +// newest-first store rows for one song +const rows = [ + { + song: "I", + platform: "spotify", + metric: "m", + value: 300, + captured_at: "2026-06-12T00:00:00+00:00", + data_source: "apify_spotify_playcount", + }, + { + song: "I", + platform: "spotify", + metric: "m", + value: 100, + captured_at: "2025-06-12T00:00:00+00:00", + data_source: "songstats", + }, +]; + +describe("getTrackMeasurements", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(resolveTrackIsrc).mockResolvedValue("I"); + vi.mocked(selectSongMeasurements).mockResolvedValue(rows as never); + }); + + it("404s when the id resolves to no ISRC", async () => { + vi.mocked(resolveTrackIsrc).mockResolvedValue(null); + const r = await getTrackMeasurements({ ...base }); + expect(r).toEqual({ error: "Unknown track id", status: 404 }); + expect(deductCredits).not.toHaveBeenCalled(); + }); + + it("404s when the track has no measurements yet", async () => { + vi.mocked(selectSongMeasurements).mockResolvedValue([] as never); + const r = await getTrackMeasurements({ ...base }); + expect((r as { status: number }).status).toBe(404); + expect(deductCredits).not.toHaveBeenCalled(); + }); + + it("returns the ascending daily series by default and charges one credit", async () => { + const r = await getTrackMeasurements({ ...base }); + expect(computePlaycountDeltas).not.toHaveBeenCalled(); + expect(deductCredits).toHaveBeenCalledWith("acc_1"); + expect(r).toEqual({ + data: { + status: "success", + id: "USQY51771120", + platform: "spotify", + metric: "platform_displayed_play_count", + series: [ + { date: "2025-06-12", value: 100, data_source: "songstats" }, + { date: "2026-06-12", value: 300, data_source: "apify_spotify_playcount" }, + ], + }, + }); + }); + + it("returns a run_rate aggregate over the trailing window when aggregate=run_rate", async () => { + vi.mocked(computePlaycountDeltas).mockReturnValue([ + { + platform: "spotify", + metric: "platform_displayed_play_count", + since: { captured_at: "2025-06-12T00:00:00+00:00", value: 100 }, + until: { captured_at: "2026-06-12T00:00:00+00:00", value: 300 }, + delta: 200, + days: 365, + run_rate_annualized: 200, + }, + ]); + + const r = await getTrackMeasurements({ ...base, aggregate: "run_rate" }); + + // window start = latest date (2026-06-12) minus 365d + expect(computePlaycountDeltas).toHaveBeenCalledWith(rows, { since: "2025-06-12" }); + expect(r).toEqual({ + data: { + status: "success", + id: "USQY51771120", + platform: "spotify", + metric: "platform_displayed_play_count", + aggregate: { kind: "run_rate", window_days: 365, delta: 200, run_rate_annualized: 200 }, + }, + }); + }); + + it("returns a null aggregate when history is insufficient for a run-rate", async () => { + vi.mocked(computePlaycountDeltas).mockReturnValue([]); + const r = await getTrackMeasurements({ ...base, aggregate: "run_rate" }); + expect((r as { data: { aggregate: unknown } }).data.aggregate).toBeNull(); + }); +}); diff --git a/lib/research/measurements/__tests__/getTrackMeasurementsHandler.test.ts b/lib/research/measurements/__tests__/getTrackMeasurementsHandler.test.ts new file mode 100644 index 000000000..22c9776c2 --- /dev/null +++ b/lib/research/measurements/__tests__/getTrackMeasurementsHandler.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getTrackMeasurementsHandler } from "../getTrackMeasurementsHandler"; +import { validateGetTrackMeasurementsRequest } from "../validateGetTrackMeasurementsRequest"; +import { getTrackMeasurements } from "../getTrackMeasurements"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: vi.fn(() => ({})) })); +vi.mock("../validateGetTrackMeasurementsRequest", () => ({ + validateGetTrackMeasurementsRequest: vi.fn(), +})); +vi.mock("../getTrackMeasurements", () => ({ getTrackMeasurements: vi.fn() })); + +const req = () => new NextRequest("http://x/api/research/tracks/I/measurements"); +const validated = { + accountId: "acc_1", + id: "I", + platform: "spotify", + metric: "platform_displayed_play_count", + windowDays: 365, +} as never; + +describe("getTrackMeasurementsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the measurements payload on success", async () => { + vi.mocked(validateGetTrackMeasurementsRequest).mockResolvedValue(validated); + vi.mocked(getTrackMeasurements).mockResolvedValue({ + data: { status: "success", id: "I", series: [] }, + }); + const res = await getTrackMeasurementsHandler(req(), "I"); + expect(res.status).toBe(200); + expect(await res.json()).toMatchObject({ status: "success", id: "I" }); + }); + + it("short-circuits with the validation response", async () => { + vi.mocked(validateGetTrackMeasurementsRequest).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }) as never, + ); + const res = await getTrackMeasurementsHandler(req(), "I"); + expect(res.status).toBe(401); + expect(getTrackMeasurements).not.toHaveBeenCalled(); + }); + + it("maps error results to error responses", async () => { + vi.mocked(validateGetTrackMeasurementsRequest).mockResolvedValue(validated); + vi.mocked(getTrackMeasurements).mockResolvedValue({ error: "Unknown track id", status: 404 }); + const res = await getTrackMeasurementsHandler(req(), "I"); + expect(res.status).toBe(404); + }); + + it("returns 500 on unexpected errors", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.mocked(validateGetTrackMeasurementsRequest).mockRejectedValue(new Error("boom")); + const res = await getTrackMeasurementsHandler(req(), "I"); + expect(res.status).toBe(500); + consoleError.mockRestore(); + }); +}); diff --git a/lib/research/measurements/__tests__/resolveTrackIsrc.test.ts b/lib/research/measurements/__tests__/resolveTrackIsrc.test.ts new file mode 100644 index 000000000..6b8dbc477 --- /dev/null +++ b/lib/research/measurements/__tests__/resolveTrackIsrc.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { resolveTrackIsrc } from "../resolveTrackIsrc"; +import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; + +vi.mock("@/lib/supabase/song_identifiers/selectSongIdentifiers", () => ({ + selectSongIdentifiers: vi.fn(), +})); + +describe("resolveTrackIsrc", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns an ISRC-shaped id unchanged without a lookup", async () => { + const out = await resolveTrackIsrc("USQY51771120"); + expect(out).toBe("USQY51771120"); + expect(selectSongIdentifiers).not.toHaveBeenCalled(); + }); + + it("resolves a Spotify track id to its ISRC via the identifier mappings", async () => { + vi.mocked(selectSongIdentifiers).mockResolvedValue([ + { song: "USQY51771120", platform: "spotify", identifier_type: "track_id", value: "6Rmabc" }, + ]); + const out = await resolveTrackIsrc("6Rmabc"); + expect(selectSongIdentifiers).toHaveBeenCalledWith({ + platform: "spotify", + identifierType: "track_id", + values: ["6Rmabc"], + }); + expect(out).toBe("USQY51771120"); + }); + + it("returns null when a non-ISRC id maps to nothing", async () => { + vi.mocked(selectSongIdentifiers).mockResolvedValue([]); + // a 22-char Spotify-style id (not ISRC-shaped) that has no mapping + expect(await resolveTrackIsrc("6rqhFgbbKwnb9MLmUQDhG6")).toBeNull(); + }); +}); diff --git a/lib/research/measurements/__tests__/validateGetAlbumMeasurementsRequest.test.ts b/lib/research/measurements/__tests__/validateGetAlbumMeasurementsRequest.test.ts new file mode 100644 index 000000000..092a6c8f1 --- /dev/null +++ b/lib/research/measurements/__tests__/validateGetAlbumMeasurementsRequest.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetAlbumMeasurementsRequest } from "../validateGetAlbumMeasurementsRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ + ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); + +const req = () => new NextRequest("http://x/api/research/albums/AL1/measurements"); + +describe("validateGetAlbumMeasurementsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + }); + + it("returns the auth response (401) when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }) as never, + ); + const r = await validateGetAlbumMeasurementsRequest(req(), "AL1"); + expect((r as NextResponse).status).toBe(401); + }); + + it("returns account + album id on success", async () => { + const r = await validateGetAlbumMeasurementsRequest(req(), "AL1"); + expect(r).toEqual({ accountId: "acc_1", spotifyAlbumId: "AL1" }); + }); +}); diff --git a/lib/research/measurements/__tests__/validateGetTrackMeasurementsRequest.test.ts b/lib/research/measurements/__tests__/validateGetTrackMeasurementsRequest.test.ts new file mode 100644 index 000000000..b4159bbd6 --- /dev/null +++ b/lib/research/measurements/__tests__/validateGetTrackMeasurementsRequest.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetTrackMeasurementsRequest } from "../validateGetTrackMeasurementsRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/credits/ensureCreditsOrShortCircuit", () => ({ + ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ validateAuthContext: vi.fn() })); + +const req = (qs = "") => + new NextRequest(`http://x/api/research/tracks/USQY51771120/measurements${qs}`); + +describe("validateGetTrackMeasurementsRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue({ accountId: "acc_1" } as never); + }); + + it("returns the auth response (401) when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }) as never, + ); + const r = await validateGetTrackMeasurementsRequest(req(), "USQY51771120"); + expect((r as NextResponse).status).toBe(401); + }); + + it("400s when aggregate is not run_rate", async () => { + const r = await validateGetTrackMeasurementsRequest(req("?aggregate=avg"), "USQY51771120"); + expect((r as NextResponse).status).toBe(400); + }); + + it("defaults platform, metric, window; no aggregate", async () => { + const r = await validateGetTrackMeasurementsRequest(req(), "USQY51771120"); + expect(r).toEqual({ + accountId: "acc_1", + id: "USQY51771120", + platform: "spotify", + metric: "platform_displayed_play_count", + aggregate: undefined, + windowDays: 365, + }); + }); + + it("parses aggregate=run_rate and window=90d", async () => { + const r = await validateGetTrackMeasurementsRequest( + req("?aggregate=run_rate&window=90d"), + "USQY51771120", + ); + expect(r).toMatchObject({ aggregate: "run_rate", windowDays: 90 }); + }); +}); diff --git a/lib/research/measurements/getAlbumMeasurements.ts b/lib/research/measurements/getAlbumMeasurements.ts new file mode 100644 index 000000000..3ffc7c24f --- /dev/null +++ b/lib/research/measurements/getAlbumMeasurements.ts @@ -0,0 +1,50 @@ +import { getAlbumPlaycounts } from "@/lib/research/playcounts/getAlbumPlaycounts"; + +const METRIC = "platform_displayed_play_count"; + +export type GetAlbumMeasurementsResult = { data: unknown } | { error: string; status: number }; + +type PlaycountRow = { + isrc: string; + spotify_track_id: string | null; + name: string | null; + platform_displayed_play_count: number; + captured_at: string; + data_source: string; +}; + +/** + * Latest measured count per track on an album, in the `measurements` shape — + * a thin remap over {@link getAlbumPlaycounts} (which serves the store and + * deducts credits). Consolidates `GET /api/research/playcounts`. + * + * @param params.accountId - The authenticated account + * @param params.spotifyAlbumId - The Spotify album id + */ +export async function getAlbumMeasurements(params: { + accountId: string; + spotifyAlbumId: string; +}): Promise { + const result = await getAlbumPlaycounts(params); + if ("error" in result) return result; + + const { playcounts } = result.data as { playcounts: PlaycountRow[] }; + const measurements = playcounts.map(p => ({ + isrc: p.isrc, + spotify_track_id: p.spotify_track_id, + name: p.name, + value: p.platform_displayed_play_count, + captured_at: p.captured_at, + data_source: p.data_source, + })); + + return { + data: { + status: "success", + id: params.spotifyAlbumId, + platform: "spotify", + metric: METRIC, + measurements, + }, + }; +} diff --git a/lib/research/measurements/getAlbumMeasurementsHandler.ts b/lib/research/measurements/getAlbumMeasurementsHandler.ts new file mode 100644 index 000000000..903739e18 --- /dev/null +++ b/lib/research/measurements/getAlbumMeasurementsHandler.ts @@ -0,0 +1,33 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { getAlbumMeasurements } from "@/lib/research/measurements/getAlbumMeasurements"; +import { validateGetAlbumMeasurementsRequest } from "@/lib/research/measurements/validateGetAlbumMeasurementsRequest"; + +/** + * GET /api/research/albums/{id}/measurements + * + * Latest measured count per track on an album, from the store. Consolidates + * `GET /api/research/playcounts`. + * + * @param request - The incoming HTTP request. + * @param id - The Spotify album id from the path. + * @returns The JSON response. + */ +export async function getAlbumMeasurementsHandler( + request: NextRequest, + id: string, +): Promise { + try { + const validated = await validateGetAlbumMeasurementsRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const result = await getAlbumMeasurements(validated); + if ("error" in result) return errorResponse(result.error, result.status); + + return successResponse(result.data as Record); + } catch (error) { + console.error("[ERROR] getAlbumMeasurementsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/measurements/getTrackMeasurements.ts b/lib/research/measurements/getTrackMeasurements.ts new file mode 100644 index 000000000..e36aa400d --- /dev/null +++ b/lib/research/measurements/getTrackMeasurements.ts @@ -0,0 +1,84 @@ +import { resolveTrackIsrc } from "@/lib/research/measurements/resolveTrackIsrc"; +import { selectSongMeasurements } from "@/lib/supabase/song_measurements/selectSongMeasurements"; +import { computePlaycountDeltas } from "@/lib/research/playcounts/computePlaycountDeltas"; +import { deductCredits } from "@/lib/research/deductCredits"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export type GetTrackMeasurementsParams = { + accountId: string; + id: string; + platform: string; + metric: string; + windowDays: number; + aggregate?: "run_rate"; +}; + +export type GetTrackMeasurementsResult = { data: unknown } | { error: string; status: number }; + +const NO_DATA_ERROR = + "No measurements for this track yet — create a historical measurement-job to backfill it"; + +/** + * A track's measured series from the store, or — when `aggregate=run_rate` — + * the trailing-window annualized run-rate (a projection of the same series via + * {@link computePlaycountDeltas}). Consolidates `track/historic-stats` and + * `track/playcount-deltas`. 404 when the id resolves to no ISRC or no + * measurements exist. Deducts research credits only on a successful read. + * + * @param params - account, provider-neutral track id, platform/metric, window + */ +export async function getTrackMeasurements( + params: GetTrackMeasurementsParams, +): Promise { + const isrc = await resolveTrackIsrc(params.id); + if (!isrc) return { error: "Unknown track id", status: 404 }; + + const rows = await selectSongMeasurements({ + song: isrc, + platform: params.platform, + metric: params.metric, + }); + if (rows.length === 0) return { error: NO_DATA_ERROR, status: 404 }; + + await deductCredits(params.accountId); + + const head = { + status: "success", + id: params.id, + platform: params.platform, + metric: params.metric, + }; + + if (params.aggregate === "run_rate") { + // window start = latest capture date minus the window + const latestDate = rows[0].captured_at.slice(0, 10); + const since = new Date( + new Date(`${latestDate}T00:00:00Z`).getTime() - params.windowDays * DAY_MS, + ) + .toISOString() + .slice(0, 10); + const deltas = computePlaycountDeltas(rows, { since }); + const match = + deltas.find(d => d.platform === params.platform && d.metric === params.metric) ?? deltas[0]; + const aggregate = match + ? { + kind: "run_rate" as const, + window_days: params.windowDays, + delta: match.delta, + run_rate_annualized: match.run_rate_annualized, + } + : null; + return { data: { ...head, aggregate } }; + } + + const series = [...rows] + .sort((a, b) => (a.captured_at < b.captured_at ? -1 : 1)) + .map(row => ({ + date: row.captured_at.slice(0, 10), + value: row.value, + data_source: row.data_source, + })); + + return { data: { ...head, series } }; +} diff --git a/lib/research/measurements/getTrackMeasurementsHandler.ts b/lib/research/measurements/getTrackMeasurementsHandler.ts new file mode 100644 index 000000000..128c88995 --- /dev/null +++ b/lib/research/measurements/getTrackMeasurementsHandler.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { successResponse } from "@/lib/networking/successResponse"; +import { getTrackMeasurements } from "@/lib/research/measurements/getTrackMeasurements"; +import { validateGetTrackMeasurementsRequest } from "@/lib/research/measurements/validateGetTrackMeasurementsRequest"; + +/** + * GET /api/research/tracks/{id}/measurements + * + * A track's measured series from the store, or — with `aggregate=run_rate` — + * the trailing-window annualized run-rate. Consolidates `track/historic-stats` + * and `track/playcount-deltas`. + * + * @param request - The incoming HTTP request. + * @param id - The provider-neutral track id from the path. + * @returns The JSON response. + */ +export async function getTrackMeasurementsHandler( + request: NextRequest, + id: string, +): Promise { + try { + const validated = await validateGetTrackMeasurementsRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const result = await getTrackMeasurements(validated); + if ("error" in result) return errorResponse(result.error, result.status); + + return successResponse(result.data as Record); + } catch (error) { + console.error("[ERROR] getTrackMeasurementsHandler:", error); + return errorResponse("Internal error", 500); + } +} diff --git a/lib/research/measurements/resolveTrackIsrc.ts b/lib/research/measurements/resolveTrackIsrc.ts new file mode 100644 index 000000000..bc085561d --- /dev/null +++ b/lib/research/measurements/resolveTrackIsrc.ts @@ -0,0 +1,23 @@ +import { selectSongIdentifiers } from "@/lib/supabase/song_identifiers/selectSongIdentifiers"; + +/** ISRC shape: 2 letters (country) + 10 alphanumerics. */ +const ISRC_PATTERN = /^[A-Za-z]{2}[A-Za-z0-9]{10}$/; + +/** + * Resolve a provider-neutral track id (ISRC or Spotify track id) to its ISRC — + * the key the measurement store is indexed by. ISRC-shaped ids pass through; + * anything else is treated as a Spotify track id and reverse-looked-up. + * + * @param id - ISRC or Spotify track id + * @returns The ISRC, or null when a non-ISRC id maps to nothing + */ +export async function resolveTrackIsrc(id: string): Promise { + if (ISRC_PATTERN.test(id)) return id; + + const rows = await selectSongIdentifiers({ + platform: "spotify", + identifierType: "track_id", + values: [id], + }); + return rows[0]?.song ?? null; +} diff --git a/lib/research/measurements/validateGetAlbumMeasurementsRequest.ts b/lib/research/measurements/validateGetAlbumMeasurementsRequest.ts new file mode 100644 index 000000000..5195f7455 --- /dev/null +++ b/lib/research/measurements/validateGetAlbumMeasurementsRequest.ts @@ -0,0 +1,28 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; + +export type ValidatedGetAlbumMeasurementsRequest = { + accountId: string; + spotifyAlbumId: string; +}; + +/** + * Validates `GET /api/research/albums/{id}/measurements` — auth + research + * credits. The album id comes from the path. + * + * @param request - The incoming HTTP request. + * @param id - The Spotify album id from the path. + */ +export async function validateGetAlbumMeasurementsRequest( + request: NextRequest, + id: string, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const short = await ensureResearchCredits(authResult.accountId); + if (short) return short; + + return { accountId: authResult.accountId, spotifyAlbumId: id }; +} diff --git a/lib/research/measurements/validateGetTrackMeasurementsRequest.ts b/lib/research/measurements/validateGetTrackMeasurementsRequest.ts new file mode 100644 index 000000000..8bdfbc681 --- /dev/null +++ b/lib/research/measurements/validateGetTrackMeasurementsRequest.ts @@ -0,0 +1,55 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { ensureResearchCredits } from "@/lib/research/ensureResearchCredits"; +import { errorResponse } from "@/lib/networking/errorResponse"; + +const DEFAULT_PLATFORM = "spotify"; +const DEFAULT_METRIC = "platform_displayed_play_count"; +const DEFAULT_WINDOW_DAYS = 365; + +export type ValidatedGetTrackMeasurementsRequest = { + accountId: string; + id: string; + platform: string; + metric: string; + aggregate?: "run_rate"; + windowDays: number; +}; + +/** + * Validates `GET /api/research/tracks/{id}/measurements` — auth, research + * credits, and the projection query params (`platform`, `metric`, `aggregate`, + * `window`). `aggregate` may only be `run_rate`; `window` like `365d` → days. + * + * @param request - The incoming HTTP request. + * @param id - The provider-neutral track id from the path. + */ +export async function validateGetTrackMeasurementsRequest( + request: NextRequest, + id: string, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const { searchParams } = new URL(request.url); + + const aggregateParam = searchParams.get("aggregate"); + if (aggregateParam !== null && aggregateParam !== "run_rate") { + return errorResponse("aggregate must be run_rate", 400); + } + + const short = await ensureResearchCredits(authResult.accountId); + if (short) return short; + + const windowMatch = (searchParams.get("window") ?? "").match(/^(\d+)d?$/); + const windowDays = windowMatch ? Number(windowMatch[1]) : DEFAULT_WINDOW_DAYS; + + return { + accountId: authResult.accountId, + id, + platform: searchParams.get("platform") ?? DEFAULT_PLATFORM, + metric: searchParams.get("metric") ?? DEFAULT_METRIC, + aggregate: aggregateParam === "run_rate" ? "run_rate" : undefined, + windowDays, + }; +}